@gaonjs/cli 0.60.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/doctor/fixers/index.js +5 -0
- package/dist/doctor/page-fetch.d.ts +8 -0
- package/dist/doctor/page-fetch.js +107 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +7 -2
- package/dist/index.js +3 -3
- package/dist/templates/project/AGENTS.md.tpl +4 -3
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/frontend.md.tpl +30 -2
- package/dist/templates/project/agents/realtime.md.tpl +36 -2
- package/package.json +5 -5
|
@@ -195,4 +195,9 @@ export const FIXER_CAPABILITIES = [
|
|
|
195
195
|
hasFixer: false,
|
|
196
196
|
note: '수동 · 공유 .env 에서 NODE_ENV 줄을 지우세요 — 모드는 명령이 정합니다(gaon dev=development · gaon serve=production · 결정 430). 지웠을 때 어떤 모드로 돌리려던 것인지는 사람이 알아야 해서 자동 정정하지 않습니다.',
|
|
197
197
|
},
|
|
198
|
+
{
|
|
199
|
+
rule: 'page-fetch',
|
|
200
|
+
hasFixer: false,
|
|
201
|
+
note: '수동 · raw fetch 를 api()/useForm 으로 바꾸려면 대응 라우트 키(<앱>:<컨트롤러>#<액션>) 추론이 필요해 기계 변환이 불가합니다 — JSON 액션은 api(), 폼은 useForm, 부득이한 커스텀 전송은 readCsrfToken() 탈출구를 쓰세요(결정 453).',
|
|
202
|
+
},
|
|
198
203
|
];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** 소스에서 내부 경로 raw fetch 호출을 찾는다(단위 테스트 진입점 · 주석 제외). */
|
|
3
|
+
export declare function internalFetchCalls(source: string): {
|
|
4
|
+
line: number;
|
|
5
|
+
path: string;
|
|
6
|
+
}[];
|
|
7
|
+
/** 세션 앱(비 JWT)의 .vue 를 훑어 내부 경로 raw fetch 를 경고로 낸다. */
|
|
8
|
+
export declare function checkPageFetch(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 세션 앱 프론트의 내부 경로 raw fetch 검출 (결정 453 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// 세션 앱의 `.vue` 에서 앱 내부 경로를 raw `fetch('/...')` 로 부르면 CSRF
|
|
4
|
+
// 토큰이 어디에도 실리지 않아 상태 변경 요청(POST/PUT/PATCH/DELETE)이 403
|
|
5
|
+
// 으로 죽는다 — 자동 부착(결정 166·341·342)은 `api()`·`useForm`·`router` 를
|
|
6
|
+
// 탈 때만 작동한다(rooms 샘플 실사용에서 강퇴/위임이 이 경로로 죽었다).
|
|
7
|
+
// csrf-wiring(결정 93)은 서버 세션 배선만 보므로 클라이언트 우회는 이 검사가
|
|
8
|
+
// 잡는다. 폼은 `useForm`, JSON 액션은 `api()` 가 정본(agents/frontend.md §2).
|
|
9
|
+
//
|
|
10
|
+
// 오탐 방지: 첫 인자가 `/` 로 시작하는 **문자열/템플릿 리터럴**일 때만 —
|
|
11
|
+
// 외부 URL(http…)·변수/식 인자(정적 판별 불가)·`//`(프로토콜-상대)는 건드리지
|
|
12
|
+
// 않는다. JWT/API 앱은 토큰 인증이라 REST + fetch 가 정본 경로 — 제외한다.
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { join, relative } from 'node:path';
|
|
16
|
+
import { usesJwtStrategy } from './auth-wiring.js';
|
|
17
|
+
// 주석을 공백 치환하되 줄바꿈은 보존(라인 번호 유지) — internal-anchor 와 동형.
|
|
18
|
+
function stripCommentsKeepLines(source) {
|
|
19
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
|
20
|
+
return source
|
|
21
|
+
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
22
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
23
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
|
|
24
|
+
}
|
|
25
|
+
// `fetch(` 의 첫 인자가 내부 경로 리터럴인 호출만 잡는다. 앞이 식별자/점이면
|
|
26
|
+
// (`myFetch(`·`api.fetch(`) 다른 함수다 — 전역 fetch 호출만 본다.
|
|
27
|
+
const INTERNAL_FETCH = /(?<![.\w$])fetch\s*\(\s*(['"`])(\/(?!\/)[^'"`\n]*)\1/g;
|
|
28
|
+
/** 소스에서 내부 경로 raw fetch 호출을 찾는다(단위 테스트 진입점 · 주석 제외). */
|
|
29
|
+
export function internalFetchCalls(source) {
|
|
30
|
+
const stripped = stripCommentsKeepLines(source);
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const m of stripped.matchAll(INTERNAL_FETCH)) {
|
|
33
|
+
const line = stripped.slice(0, m.index ?? 0).split('\n').length;
|
|
34
|
+
out.push({ line, path: m[2] });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/** 세션 앱(비 JWT)의 .vue 를 훑어 내부 경로 raw fetch 를 경고로 낸다. */
|
|
39
|
+
export async function checkPageFetch(cwd) {
|
|
40
|
+
const appsDir = join(cwd, 'apps');
|
|
41
|
+
const issues = [];
|
|
42
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
43
|
+
// JWT/API 앱은 REST + fetch 가 정본 — 제외(csrf-wiring 과 동일 판정).
|
|
44
|
+
const acPath = join(appsDir, app, 'app.config.ts');
|
|
45
|
+
if (existsSync(acPath)) {
|
|
46
|
+
const acSource = await readFile(acPath, 'utf8').catch(() => '');
|
|
47
|
+
if (usesJwtStrategy(acSource))
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const abs of await walkVue(join(appsDir, app))) {
|
|
51
|
+
const source = await readFile(abs, 'utf8').catch(() => '');
|
|
52
|
+
const rel = relative(cwd, abs);
|
|
53
|
+
for (const hit of internalFetchCalls(source)) {
|
|
54
|
+
issues.push({
|
|
55
|
+
rule: 'page-fetch',
|
|
56
|
+
level: 'warning',
|
|
57
|
+
file: rel,
|
|
58
|
+
line: hit.line,
|
|
59
|
+
message: `내부 경로 raw fetch 발견: ${rel}:${hit.line} 이 \`fetch('${hit.path}')\` 로 앱 내부를 ` +
|
|
60
|
+
`직접 호출합니다. 세션 앱의 CSRF 토큰 자동 부착(결정 166·341·342)은 \`api()\`·\`useForm\`·` +
|
|
61
|
+
`\`router\` 를 탈 때만 작동해, raw fetch 의 상태 변경 요청은 403 으로 죽습니다(결정 453).\n` +
|
|
62
|
+
`→ JSON 액션(강퇴·위임·좋아요 등 커맨드 포함)은 \`gaonjs/vue\` 의 \`api()\` 로 부르세요: ` +
|
|
63
|
+
`\`await api('<앱>:<컨트롤러>#<액션>', { ...params })\` (agents/frontend.md §2).\n` +
|
|
64
|
+
`→ 폼 제출은 \`useForm(...).post()\`, DELETE 등은 \`router.delete(...)\` (agents/web.md §4).\n` +
|
|
65
|
+
`→ 부득이한 커스텀 전송은 \`readCsrfToken()\` 으로 토큰을 직접 실으세요(탈출구 · 결정 342).`,
|
|
66
|
+
detail: { file: rel, line: hit.line, path: hit.path },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { rule: 'page-fetch', issues };
|
|
72
|
+
}
|
|
73
|
+
async function safeListDirs(dir) {
|
|
74
|
+
try {
|
|
75
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
76
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** 앱 하위 .vue 절대경로(내림차순 아님 · node_modules/.gaon 제외). */
|
|
83
|
+
async function walkVue(dir) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const walk = async (d) => {
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
const abs = join(d, e.name);
|
|
95
|
+
if (e.isDirectory()) {
|
|
96
|
+
if (e.name === 'node_modules' || e.name === '.gaon')
|
|
97
|
+
continue;
|
|
98
|
+
await walk(abs);
|
|
99
|
+
}
|
|
100
|
+
else if (e.isFile() && e.name.endsWith('.vue')) {
|
|
101
|
+
out.push(abs);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
await walk(dir);
|
|
106
|
+
return out.sort();
|
|
107
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | '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' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env';
|
|
1
|
+
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | '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' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env' | 'page-fetch';
|
|
2
2
|
export type DoctorLevel = 'passed' | 'warning' | 'error';
|
|
3
3
|
export interface DoctorCheck {
|
|
4
4
|
readonly rule: DoctorRule;
|
package/dist/doctor.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
|
|
|
33
33
|
* 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
|
|
34
34
|
*/
|
|
35
35
|
/**
|
|
36
|
-
* doctor 정적 검사
|
|
36
|
+
* doctor 정적 검사 33종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
|
|
37
37
|
* 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
|
|
38
38
|
* 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
|
|
39
39
|
*/
|
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
|
-
*
|
|
4
|
+
* 33 검사를 조립한다:
|
|
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 규칙)
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* 29) channel-collision (§7 · 앱간 동명 채널 = 전역 subject·프레즌스 병합 error)
|
|
35
35
|
* 30) channel-instance-authorize (결정 440 · authorize 없는 인스턴스 채널 = 임의 인스턴스 공개 입장 경고)
|
|
36
36
|
* 31) dotenv-node-env (결정 430 · 공유 .env 의 NODE_ENV = 모드 누출 경고)
|
|
37
|
+
* 32) page-fetch (결정 453 · 세션 앱 .vue 의 내부 경로 raw fetch = CSRF 미부착 403 경고)
|
|
37
38
|
*
|
|
38
39
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
39
40
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -78,6 +79,7 @@ import { checkLocaleParity } from './doctor/locale-parity.js';
|
|
|
78
79
|
import { checkChannelCollision } from './doctor/channel-collision.js';
|
|
79
80
|
import { checkChannelInstanceAuthorize } from './doctor/channel-instance-authorize.js';
|
|
80
81
|
import { checkDotenvNodeEnv } from './doctor/dotenv-node-env.js';
|
|
82
|
+
import { checkPageFetch } from './doctor/page-fetch.js';
|
|
81
83
|
import { renderHuman, renderJson } from './doctor/reporter.js';
|
|
82
84
|
import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
83
85
|
import { makeResult, } from './doctor/types.js';
|
|
@@ -114,7 +116,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
|
|
|
114
116
|
* 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
|
|
115
117
|
*/
|
|
116
118
|
/**
|
|
117
|
-
* doctor 정적 검사
|
|
119
|
+
* doctor 정적 검사 33종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
|
|
118
120
|
* 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
|
|
119
121
|
* 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
|
|
120
122
|
*/
|
|
@@ -151,6 +153,7 @@ export const ALL_RULES = [
|
|
|
151
153
|
'channel-collision',
|
|
152
154
|
'channel-instance-authorize',
|
|
153
155
|
'dotenv-node-env',
|
|
156
|
+
'page-fetch',
|
|
154
157
|
];
|
|
155
158
|
/**
|
|
156
159
|
* `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
|
|
@@ -191,6 +194,7 @@ export const RULE_SUMMARIES = {
|
|
|
191
194
|
'channel-collision': '앱간 동명 채널',
|
|
192
195
|
'channel-instance-authorize': '인스턴스 채널 authorize',
|
|
193
196
|
'dotenv-node-env': '.env NODE_ENV',
|
|
197
|
+
'page-fetch': '세션 앱 raw fetch',
|
|
194
198
|
};
|
|
195
199
|
const CHECKERS = {
|
|
196
200
|
'response-mixing': checkResponseMixing,
|
|
@@ -225,6 +229,7 @@ const CHECKERS = {
|
|
|
225
229
|
'channel-collision': checkChannelCollision,
|
|
226
230
|
'channel-instance-authorize': checkChannelInstanceAuthorize,
|
|
227
231
|
'dotenv-node-env': checkDotenvNodeEnv,
|
|
232
|
+
'page-fetch': checkPageFetch,
|
|
228
233
|
};
|
|
229
234
|
/**
|
|
230
235
|
* 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
|
package/dist/index.js
CHANGED
|
@@ -156,7 +156,7 @@ function renderHelp(version = VERSION) {
|
|
|
156
156
|
* 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
|
|
157
157
|
*/
|
|
158
158
|
export function parseDoctorChecks(argv) {
|
|
159
|
-
// 인정 집합은 doctor.ts 의 ALL_RULES(정본
|
|
159
|
+
// 인정 집합은 doctor.ts 의 ALL_RULES(정본 33종)를 단일 출처로 쓴다 — 과거
|
|
160
160
|
// 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
|
|
161
161
|
// 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
|
|
162
162
|
const isKnown = (s) => ALL_RULES.includes(s);
|
|
@@ -177,7 +177,7 @@ export function parseDoctorChecks(argv) {
|
|
|
177
177
|
}
|
|
178
178
|
// 결정 411: 모르는 이름은 여전히 무시하되(안전 방향 — 전체 검사로 넓어짐) **조용히**
|
|
179
179
|
// 넘기지 않는다. 오타 하나가 "그 검사만 돌렸다" 는 착각으로 이어지고, 전부 오타면
|
|
180
|
-
//
|
|
180
|
+
// 33종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
|
|
181
181
|
if (unknown.length > 0) {
|
|
182
182
|
process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
|
|
183
183
|
` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +
|
|
@@ -497,7 +497,7 @@ export function runCli(argv, opts = {}) {
|
|
|
497
497
|
});
|
|
498
498
|
return;
|
|
499
499
|
}
|
|
500
|
-
// `gaon doctor` — 정적 검사(M9-E ·
|
|
500
|
+
// `gaon doctor` — 정적 검사(M9-E · 33 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
|
|
501
501
|
// 선택 실행, --json 은 자동화 파싱용.
|
|
502
502
|
// exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
|
|
503
503
|
if (argv[0] === "doctor") {
|
|
@@ -113,7 +113,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
113
113
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
114
114
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
115
115
|
|
|
116
|
-
### 2.2 `gaon doctor` 검사
|
|
116
|
+
### 2.2 `gaon doctor` 검사 33종
|
|
117
117
|
|
|
118
118
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
119
119
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -147,6 +147,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
147
147
|
30. `channel-instance-authorize` — `instance: true` 채널(파라미터화 채널 · 결정 440)에 `authorize` 가 없음 = **경고**. 인스턴스 채널은 임의 문자열 키로 무한 실행 인스턴스(`/gaon/ws/<이름>/<인스턴스>`)가 열리므로, authorize 가 없으면 누구나 아무 인스턴스에나 입장한다. 매치·스레드처럼 참가자가 정해진 채널이면 `authorize(ctx)` 에서 `ctx.instance` 로 입장을 판정하라 — 공개 관전형(누구나 입장)이 의도면 무시해도 된다(재수출 정의는 대상 모듈을 따라가 판정 · `agents/realtime.md` §2.7)
|
|
148
148
|
31. `agents-docs-stale` — 이 문서(`AGENTS.md`)·`agents/*.md` 사본이 **설치된 gaonjs 템플릿(정본)과 다름** = **경고**(byte 비교). gaonjs 업그레이드 후 관례 문서가 옛 채로 남으면 AI 가 낡은 관례·없는 표면으로 코드를 짠다 — `gaon g agents-docs` 로 재동기하라(미리보기 `--check` · 멱등 · 사본에 직접 적은 내용은 프로젝트 소유 문서(CLAUDE.md)로 옮긴 뒤 — 이 문서들은 프레임웍 정본 사본이다) (결정 449)
|
|
149
149
|
32. `dotenv-node-env` — 공유 `.env`(·`.env.local`·`.env.example`)에 `NODE_ENV` 가 설정됨 = **경고**. **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production(결정 430). 이 파일들은 개발·운영이 함께 읽으므로 값을 박으면 모드가 양쪽으로 샌다: `production` 이면 `gaon dev` 가 쿠키 Secure·dev 플레이스홀더 secret 거부로 죽고, `development` 면 운영 `gaon serve` 에서 프로덕션 안전장치(플레이스홀더 secret 거부·쿠키 Secure·락 in-memory 폴백 차단)가 통째로 꺼진다(**부팅은 green, 보안만 꺼짐**). `.env` 에서 그 줄을 지우고, 모드별 값이 필요하면 `.env.development`/`.env.production` 오버레이에, 일회성이면 명령 앞에 붙인다(`NODE_ENV=production gaon serve`) — 모드별 오버레이 파일은 검사 대상이 아니다 (결정 430)
|
|
150
|
+
33. `page-fetch` — 세션 앱 `.vue` 가 앱 내부 경로를 raw `fetch('/...')` 로 호출 = **경고**. CSRF 토큰 자동 부착(결정 166·341·342)은 `api()`·`useForm`·`router` 를 탈 때만 작동해, raw fetch 의 상태 변경 요청(POST/PUT/PATCH/DELETE)은 403 으로 죽는다(rooms 샘플 실사용에서 강퇴/위임이 이 경로로 죽었다). JSON 액션(강퇴·위임·좋아요 등 커맨드 포함)은 `api()`, 폼은 `useForm`, 부득이한 커스텀 전송은 `readCsrfToken()` 탈출구 — 첫 인자가 `/` 로 시작하는 문자열/템플릿 리터럴만 검출(외부 URL·변수 인자 오탐 제외), JWT/API 앱은 REST + fetch 가 정본이라 제외 (결정 453 · `agents/frontend.md` §2)
|
|
150
151
|
|
|
151
152
|
## 3. 로직 배치 One Way 판단표
|
|
152
153
|
|
|
@@ -171,7 +172,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
171
172
|
|---|---|---|
|
|
172
173
|
| 지금 페이지의 데이터를 다시 받기 (필터 변경·새로고침·무한 스크롤) | **Inertia partial reload** — 같은 액션 재호출, 필요한 props만 | §6.1 |
|
|
173
174
|
| 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) — 서버 개시는 `broadcast(name,data)`, 클라 메시지 응답은 `ctx.broadcast` | §7 |
|
|
174
|
-
|
|
|
175
|
+
| 페이지 리로드가 필요 없는 앱 내부 요청 — 조회(자동완성·옵션)든 **상태 변경 커맨드(강퇴·위임·좋아요·토글)**든 | **JSON 액션 + `api()` 클라이언트** (`agents/web.md`·`agents/frontend.md` · CSRF 자동 부착) | E-3 · 결정 452 |
|
|
175
176
|
| 외부에 공개하는 API (모바일 앱·서드파티) | **별도 API 앱 + JWT 옵션** | §3, §7 |
|
|
176
177
|
|
|
177
178
|
### 3.4 잡 발행 위치 (결정 32)
|
|
@@ -207,7 +208,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
207
208
|
```bash
|
|
208
209
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
|
|
209
210
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
210
|
-
gaon doctor # 정적 검사
|
|
211
|
+
gaon doctor # 정적 검사 33종 (§2.2)
|
|
211
212
|
```
|
|
212
213
|
|
|
213
214
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -86,7 +86,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
88
|
gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
|
|
89
|
-
gaon doctor # 정적 검사
|
|
89
|
+
gaon doctor # 정적 검사 33종 (상세 AGENTS §2.2)
|
|
90
90
|
npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
|
|
91
91
|
```
|
|
92
92
|
|
|
@@ -114,6 +114,30 @@ async function search(q: string) {
|
|
|
114
114
|
</script>
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
+
**상태 변경 커맨드도 같은 경로다(결정 452)** — 강퇴·위임·좋아요·토글처럼
|
|
118
|
+
페이지의 버튼이 서버 상태를 바꾸는 JSON 액션 호출도 `api()` 가 정본이다
|
|
119
|
+
(폼이 아니므로 `useForm` 이 아니고, `fetch()` 는 CSRF 미부착으로 403 — 아래
|
|
120
|
+
함정). POST 라우트 `r.post('/posts/:id/like', 'posts#like')` 기준:
|
|
121
|
+
|
|
122
|
+
```vue
|
|
123
|
+
<script setup lang="ts">
|
|
124
|
+
import { api, isApiError } from 'gaonjs/vue'
|
|
125
|
+
|
|
126
|
+
async function like(postId: string) {
|
|
127
|
+
try {
|
|
128
|
+
// :id 자리표시자는 params 에서 채워지고, 남는 값은 JSON 본문으로 실린다.
|
|
129
|
+
// CSRF 토큰은 프레임웍이 X-CSRF-Token 헤더로 자동 부착한다(결정 166·341).
|
|
130
|
+
const { likes } = await api('web:posts#like', { id: postId })
|
|
131
|
+
return likes
|
|
132
|
+
} catch (e) {
|
|
133
|
+
// 4xx/5xx 는 ApiError throw — res.ok 검사가 아니라 catch 로 받는다(결정 304).
|
|
134
|
+
if (isApiError(e) && e.status === 403) return alert('권한이 없습니다.')
|
|
135
|
+
throw e
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
</script>
|
|
139
|
+
```
|
|
140
|
+
|
|
117
141
|
- **시그니처** — `api(key, params?, opts?)`. 제네릭 타입 인자를 직접
|
|
118
142
|
붙이지 않는다 — `key` 값 자체가 `keyof GaonRouteMap` 으로 좁혀져
|
|
119
143
|
반환 타입을 결정한다 (`packages/vue/src/api.ts`).
|
|
@@ -459,8 +483,12 @@ async function runSearch(q: string) {
|
|
|
459
483
|
결정한다.
|
|
460
484
|
- **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
|
|
461
485
|
doctor **shared-purity** 위반(`.ts`·`.vue` 공통 · 결정 217). 데이터는 props/인자로.
|
|
462
|
-
-
|
|
463
|
-
|
|
486
|
+
- **세션 앱 페이지에서 `fetch()` 금지 — 폼이든 버튼 액션이든**(결정 452) —
|
|
487
|
+
폼은 `useForm(...).post()` (`agents/web.md` §4 · 결정 64), 강퇴·위임·좋아요
|
|
488
|
+
같은 상태 변경 커맨드는 `api()` (§2). raw `fetch()` 는 CSRF 토큰이 안 실려
|
|
489
|
+
상태 변경 요청(POST/PUT/PATCH/DELETE)이 **403 으로 죽는다** — 자동 부착
|
|
490
|
+
(결정 166·341·342)은 `api()`·`useForm`·`router` 를 탈 때만 작동한다.
|
|
491
|
+
REST + `fetch()` 는 API 앱(JWT) 전용이다.
|
|
464
492
|
- **내부 경로 일반 `<a href="/...">` 금지** — 클릭마다 전체 문서를 다시
|
|
465
493
|
로드해 SPA 상태가 초기화된다. 앱 내부 이동은 `gaonjs/vue` 의 `Link`
|
|
466
494
|
(`<Link href="/...">`) 또는 `router.visit(...)`, 외부 URL·`target="_blank"`
|
|
@@ -340,16 +340,21 @@ import { Room } from '../models/Room.js'
|
|
|
340
340
|
|
|
341
341
|
export default on(InstanceClosed, async ({ channel, instance }) => {
|
|
342
342
|
if (channel !== 'match') return
|
|
343
|
-
await Room.where('key', '=', instance).
|
|
343
|
+
await Room.where('key', '=', instance).deleteAll()
|
|
344
344
|
broadcast('lobby', { type: 'room-closed', key: instance })
|
|
345
345
|
})
|
|
346
346
|
```
|
|
347
347
|
|
|
348
|
+
- **체인에 `.delete()` 는 없다** — 단건은 `rec.delete()` · 벌크는 체인
|
|
349
|
+
`deleteAll()` (`agents/data.md` §벌크 계약). `where(...).delete()` 는 컴파일이
|
|
350
|
+
통과하는 것처럼 보여도 런타임 TypeError 로 리스너가 죽고, 재시도 소진 후
|
|
351
|
+
이벤트가 폐기돼 **방이 영영 안 지워진다**(rooms 샘플 실측 · 결정 451).
|
|
348
352
|
- **핸들러는 워커 딱 하나에서만 돈다** — 이벤트 스트림(JetStream)의 리스너
|
|
349
353
|
durable 컨슈머를 전 `gaon work` 가 공유하므로, 여러 워커를 띄워도 이벤트당
|
|
350
354
|
한 워커만 처리한다(개발자가 서버를 고르지 않는다). at-least-once 라
|
|
351
355
|
**핸들러는 멱등하게**(이벤트 배터리 공통 관례 — 위 예시의 create 는 key
|
|
352
|
-
unique + upsert 또는 존재 검사로 감싸는 것이
|
|
356
|
+
unique + upsert 또는 존재 검사로 감싸는 것이 안전하고, closed 쪽 `deleteAll`
|
|
357
|
+
은 0행 삭제 = no-op 이라 그 자체로 멱등이다).
|
|
353
358
|
- **일시적 방 vs 영속 방** — 일시적 방(익명 대화방 등)은 이 두 이벤트가 곧
|
|
354
359
|
생성/삭제다. 영속 방(게임 매치·게시물 스레드)은 방 row 를 서비스로 먼저
|
|
355
360
|
만들고(DB = 진실 원천 · 참가 authorize 도 그 row 로) 이 이벤트는 **점유
|
|
@@ -426,6 +431,35 @@ present 대상 수(0 = 부재 = no-op 멱등). **누가 kick 할 수 있는가
|
|
|
426
431
|
await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위반' })
|
|
427
432
|
```
|
|
428
433
|
|
|
434
|
+
**버튼 → 컨트롤러 → `api()` 완결 경로(결정 452).** kick 은 서버 전용
|
|
435
|
+
표면이라 페이지의 강퇴 버튼은 **JSON 액션을 `api()` 로** 부른다 — 폼이
|
|
436
|
+
아니므로 `useForm` 이 아니고, raw `fetch()` 는 CSRF 미부착으로 403 이다
|
|
437
|
+
(`agents/frontend.md` §2). 위임(delegate)·방 설정 변경류 **방장 커맨드도
|
|
438
|
+
전부 같은 경로**다:
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
// apps/web/routes.ts
|
|
442
|
+
r.post('/rooms/:key/kick', 'rooms#kick')
|
|
443
|
+
|
|
444
|
+
// apps/web/controllers/rooms.ts — 인가(방장 검사) → kick → this.json
|
|
445
|
+
async kick() {
|
|
446
|
+
this.requireAuth()
|
|
447
|
+
const { key, targetUserId } = this.params({ _row: {} as { key: string; targetUserId: string } })
|
|
448
|
+
const room = await Room.where('key', '=', key).first()
|
|
449
|
+
if (!room) return this.json({ message: '존재하지 않는 방입니다.' }, 404)
|
|
450
|
+
if (String(room.ownerId) !== String((this.auth.user as { id: bigint }).id))
|
|
451
|
+
return this.json({ message: '방장만 강퇴할 수 있습니다.' }, 403)
|
|
452
|
+
await kick('room', `user:${targetUserId}`, { instance: key, reason: '방장에 의해 강퇴되었습니다.' })
|
|
453
|
+
return this.json({ success: true })
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
// apps/web/pages/Rooms/Show.vue — CSRF 는 api() 가 자동 부착(결정 166·341)
|
|
459
|
+
import { api, isApiError } from 'gaonjs/vue'
|
|
460
|
+
await api('web:rooms#kick', { key: room.key, targetUserId }) // :key 는 자리표시자, 나머지는 JSON 바디
|
|
461
|
+
```
|
|
462
|
+
|
|
429
463
|
- 마지막 멤버를 kick 하면 `InstanceClosed` 가 **자동 발화**한다(§2.8 앵커
|
|
430
464
|
그대로 · 특례 없음).
|
|
431
465
|
- 전달은 at-most-once(즉시성 도구) — **영구 차단 보증은 ban 패턴(③)이
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
"typescript": "^5.9.0",
|
|
34
34
|
"vite": "^7.0.0",
|
|
35
35
|
"@gaonjs/async": "0.22.0",
|
|
36
|
-
"@gaonjs/config": "0.25.
|
|
37
|
-
"@gaonjs/mail": "0.5.1",
|
|
38
|
-
"@gaonjs/i18n": "0.3.1",
|
|
39
|
-
"@gaonjs/data": "0.25.3",
|
|
36
|
+
"@gaonjs/config": "0.25.7",
|
|
40
37
|
"@gaonjs/core": "0.3.0",
|
|
38
|
+
"@gaonjs/i18n": "0.3.1",
|
|
39
|
+
"@gaonjs/data": "0.25.4",
|
|
40
|
+
"@gaonjs/mail": "0.5.1",
|
|
41
41
|
"@gaonjs/web": "0.31.2"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|