@gaonjs/cli 0.28.0 → 0.30.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.
@@ -279,10 +279,12 @@ function writeRegenLine(regen) {
279
279
  process.stdout.write(' · .gaon 재생성 — skipped (domain/schema · apps/* 없음)\n');
280
280
  return;
281
281
  }
282
+ // 실 출력 경로를 그대로 찍는다(W2): tables.d.ts 는 루트 `.gaon/`, routes.d.ts 는
283
+ // 앱별 `apps/<앱>/.gaon/`. 옛 로그는 `web/routes.d.ts` 로 찍어 실 경로를 오도했다.
282
284
  const parts = [];
283
285
  if (regen.tables)
284
- parts.push('tables.d.ts');
286
+ parts.push('.gaon/tables.d.ts');
285
287
  for (const app of regen.apps)
286
- parts.push(`${app}/routes.d.ts`);
288
+ parts.push(`apps/${app}/.gaon/routes.d.ts`);
287
289
  process.stdout.write(` ✓ .gaon 재생성 — ${parts.join(', ')}\n`);
288
290
  }
@@ -0,0 +1,29 @@
1
+ import { type RegenResult } from '../dev.js';
2
+ export interface GenCommandOptions {
3
+ readonly cwd?: string;
4
+ readonly json?: boolean;
5
+ }
6
+ export interface GenResult {
7
+ readonly ok: boolean;
8
+ /** 재생성이 스킵됐는가(domain/schema · apps/* 없음). */
9
+ readonly skipped: boolean;
10
+ /** tables.d.ts 를 재생성했는가. */
11
+ readonly tables: boolean;
12
+ /** routes.d.ts·routes.manifest.ts 를 재생성한 앱 이름. */
13
+ readonly apps: readonly string[];
14
+ /** 실패 시 에러 + 수리 안내. */
15
+ readonly error?: string;
16
+ }
17
+ /**
18
+ * cwd 관례로 프로젝트 .gaon 을 1회 전체 재생성한다(검사·서버 없이). 생성기는 사용자
19
+ * .ts 를 동적 import 하므로 `.js`→`.ts` 해석 훅을 먼저 등록한다(gaon check 와 동일).
20
+ * 스키마·앱이 하나도 없으면 재생성 대상 없음으로 스킵한다. 실패는 throw 로 올린다.
21
+ */
22
+ export declare function regenerateProjectGaon(cwd: string): Promise<RegenResult & {
23
+ skipped: boolean;
24
+ }>;
25
+ /**
26
+ * `gaon gen` 진입점. .gaon 을 재생성하고 결과를 보고한다. 재생성 대상이 없으면
27
+ * 스킵으로 리포트(우회가 아니라 "설정 부족" 노출). 실패 시 exit 1 + 수리 안내.
28
+ */
29
+ export declare function runGenCommand(opts?: GenCommandOptions): Promise<number>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @gaonjs/cli · `gaon gen` — .gaon 타입 브리지 + 런타임 매니페스트 재생성 (결정 127)
3
+ *
4
+ * `gaon dev`(워처)·`gaon check`(검사 직전) 는 이미 재생성을 편승시키지만, **개발 서버
5
+ * 없이 재생성만** 필요한 경로가 있다:
6
+ * · 프로덕션 빌드 — 스캐폴드 `build` 스크립트가 `gaon gen && vite build` 로 돈다.
7
+ * .gaon/ 은 gitignore 대상이라 fresh-clone 에는 없다 — 값 import 인
8
+ * routes.manifest.ts 가 빌드 전에 존재해야 하므로 빌드가 재생성을 탄다(결정 127 · C-1).
9
+ * · 편집기 타입 즉시 반영 — 사용자가 손으로 한 번 돌려 routes.d.ts·tables.d.ts 를 채운다.
10
+ *
11
+ * 규칙 3(결정 127): routes 축은 routes.d.ts(타입) + routes.manifest.ts(런타임 값) 2파일로
12
+ * 물성화된다 — generateRoutesDts 한 번이 둘을 함께 낳는다.
13
+ *
14
+ * §9 실 인프라 · 목업 X — 생성기는 사용자 스키마·컨트롤러 .ts 를 실제로 동적 import 한다.
15
+ */
16
+ import { generateTablesDts } from '@gaonjs/data';
17
+ import { generateRoutesDts } from '@gaonjs/web';
18
+ import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
19
+ import { registerTsResolve } from '../tsResolve.js';
20
+ /**
21
+ * cwd 관례로 프로젝트 .gaon 을 1회 전체 재생성한다(검사·서버 없이). 생성기는 사용자
22
+ * .ts 를 동적 import 하므로 `.js`→`.ts` 해석 훅을 먼저 등록한다(gaon check 와 동일).
23
+ * 스키마·앱이 하나도 없으면 재생성 대상 없음으로 스킵한다. 실패는 throw 로 올린다.
24
+ */
25
+ export async function regenerateProjectGaon(cwd) {
26
+ const layout = resolveDevLayout(cwd);
27
+ if (!layout.schemaDir && layout.apps.length === 0) {
28
+ return { tables: false, apps: [], skipped: true };
29
+ }
30
+ registerTsResolve();
31
+ const result = await regenerateGaonOnce(layout, {
32
+ regenerateTables: generateTablesDts,
33
+ regenerateRoutes: generateRoutesDts,
34
+ });
35
+ return { ...result, skipped: false };
36
+ }
37
+ /**
38
+ * `gaon gen` 진입점. .gaon 을 재생성하고 결과를 보고한다. 재생성 대상이 없으면
39
+ * 스킵으로 리포트(우회가 아니라 "설정 부족" 노출). 실패 시 exit 1 + 수리 안내.
40
+ */
41
+ export async function runGenCommand(opts = {}) {
42
+ const cwd = opts.cwd ?? process.cwd();
43
+ const json = opts.json ?? false;
44
+ let result;
45
+ try {
46
+ const r = await regenerateProjectGaon(cwd);
47
+ result = { ok: true, skipped: r.skipped, tables: r.tables, apps: r.apps };
48
+ }
49
+ catch (err) {
50
+ const msg = err instanceof Error ? err.message : String(err);
51
+ result = {
52
+ ok: false,
53
+ skipped: false,
54
+ tables: false,
55
+ apps: [],
56
+ error: `.gaon 재생성에 실패했습니다: ${msg}\n` +
57
+ `→ domain/schema/*.ts 와 apps/*/routes.ts·controllers/*.ts 의 구문 오류를 고친 뒤 다시 실행하세요.`,
58
+ };
59
+ }
60
+ if (json) {
61
+ process.stdout.write(JSON.stringify(result) + '\n');
62
+ }
63
+ else if (!result.ok) {
64
+ process.stderr.write(` ✗ gaon gen — 실패\n ${(result.error ?? '').split('\n').join('\n ')}\n`);
65
+ }
66
+ else if (result.skipped) {
67
+ process.stdout.write(' · gaon gen — 재생성 대상 없음 (domain/schema · apps/* 확인)\n');
68
+ }
69
+ else {
70
+ const parts = [];
71
+ if (result.tables)
72
+ parts.push('.gaon/tables.d.ts');
73
+ for (const app of result.apps) {
74
+ parts.push(`apps/${app}/.gaon/routes.d.ts`, `apps/${app}/.gaon/routes.manifest.ts`);
75
+ }
76
+ process.stdout.write(` ✓ gaon gen — ${parts.join(', ')}\n`);
77
+ }
78
+ return result.ok ? 0 : 1;
79
+ }
@@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url';
24
24
  import { readFileSync } from 'node:fs';
25
25
  import { renderProjectFiles } from '../templates/index.js';
26
26
  import { writeUiKitScaffold } from '../uikit.js';
27
+ import { regenerateProjectGaon } from './gen.js';
27
28
  /** 이름 유효성 — npm 패키지명 규칙(단순 부분)만 검사. */
28
29
  function validateProjectName(name) {
29
30
  if (!name)
@@ -224,6 +225,22 @@ export async function runNewCommand(name, opts = {}) {
224
225
  return 1;
225
226
  }
226
227
  }
228
+ // C-1(결정 127) — 설치 성공 후 .gaon 을 한 번 재생성한다. 생성기가 사용자 .ts 를
229
+ // 동적 import 하므로 node_modules 가 필요하다(skipInstall 이면 건너뜀 — dev/check/build
230
+ // 가 재생성을 탄다). 값 import 인 routes.manifest.ts 가 생겨 gaon new 직후 vite build 도
231
+ // 바로 돌고, routes.d.ts 로 편집기 타입이 즉시 잡힌다. 실패해도 스캐폴드는 성공으로
232
+ // 둔다(best-effort · 재생성은 dev/check/gen 이 상시 보장) — 경고만 남긴다.
233
+ if (installReport.ran && installReport.exitCode === 0) {
234
+ try {
235
+ await regenerateProjectGaon(root);
236
+ }
237
+ catch (err) {
238
+ if (!json) {
239
+ process.stderr.write(` · .gaon 초기 재생성 경고: ${err instanceof Error ? err.message : String(err)}\n` +
240
+ ` → gaon dev·gaon check·gaon gen 이 재생성하니 무시해도 됩니다.\n`);
241
+ }
242
+ }
243
+ }
227
244
  // git init + 첫 커밋 — 스킵 시 그대로 넘어감.
228
245
  const gitReport = { ran: false, skipped: opts.skipGit === true, initialized: false, firstCommit: false };
229
246
  if (!opts.skipGit) {
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { startDev, resolveDevLayout, regenerateGaonOnce, type DevDeps, type DevL
3
3
  export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
4
4
  export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, type DevConsole, type DevConsoleOptions, type DevSource, type DevLevel, type ComposeStatus, type ComposeUpOptions, type EnsureInfraResult, type DockerLocateOptions, type TscWatcherOptions, type TscWatcherHandle, type RestartWatcherOptions, type RestartWatcherHandle, } from "./dev/index.js";
5
5
  export { runCheckCommand, type CheckCommandOptions, type CheckStep, type CheckStepStatus, type CheckStepResult, } from "./commands/check.js";
6
+ export { runGenCommand, regenerateProjectGaon, type GenCommandOptions, type GenResult, } from "./commands/gen.js";
6
7
  export { runNewCommand, type NewCommandOptions, type NewCommandResult } from "./commands/new.js";
7
8
  export { runConsoleCommand, type ConsoleCommandOptions } from "./commands/console.js";
8
9
  export { runTestCommand, type TestCommandOptions, type TestScope } from "./commands/test.js";
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@
13
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
+ import { runGenCommand } from "./commands/gen.js";
16
17
  import { runNewCommand } from "./commands/new.js";
17
18
  import { runConsoleCommand } from "./commands/console.js";
18
19
  import { runTestCommand } from "./commands/test.js";
@@ -30,6 +31,7 @@ export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
30
31
  export { runDevCommand } from "./commands/dev.js";
31
32
  export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, } from "./dev/index.js";
32
33
  export { runCheckCommand, } from "./commands/check.js";
34
+ export { runGenCommand, regenerateProjectGaon, } from "./commands/gen.js";
33
35
  export { runNewCommand } from "./commands/new.js";
34
36
  export { runConsoleCommand } from "./commands/console.js";
35
37
  export { runTestCommand } from "./commands/test.js";
@@ -99,6 +101,7 @@ function renderHelp(version = VERSION) {
99
101
  " gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
100
102
  " gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
101
103
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
104
+ " gaon gen .gaon 타입 브리지 + api() 런타임 매니페스트만 재생성 (서버·검사 없이 · build 전제 · --json)",
102
105
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
103
106
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
104
107
  " gaon doctor 정적 검사 (24 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전)",
@@ -241,6 +244,20 @@ export function runCli(argv, opts = {}) {
241
244
  });
242
245
  return;
243
246
  }
247
+ // `gaon gen` — .gaon 타입 브리지 + 런타임 매니페스트만 재생성(서버·검사 없이 · 결정 127).
248
+ // 스캐폴드 `build` 스크립트(`gaon gen && vite build`)와 편집기 타입 즉시 반영에 쓴다.
249
+ if (argv[0] === "gen") {
250
+ void runGenCommand({ json: argv.includes("--json") })
251
+ .then((code) => {
252
+ process.exitCode = code;
253
+ })
254
+ .catch((err) => {
255
+ const msg = err instanceof Error ? err.message : String(err);
256
+ process.stderr.write(` ✗ gaon gen 실패: ${msg}\n`);
257
+ process.exitCode = 1;
258
+ });
259
+ return;
260
+ }
244
261
  // `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
245
262
  // 선택 실행, --json 은 자동화 파싱용.
246
263
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
@@ -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} 에서 실행 중입니다.`)
@@ -154,7 +154,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
154
154
  | 상황 | 경로 | 근거 |
155
155
  |---|---|---|
156
156
  | 지금 페이지의 데이터를 다시 받기 (필터 변경·새로고침·무한 스크롤) | **Inertia partial reload** — 같은 액션 재호출, 필요한 props만 | §6.1 |
157
- | 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) | §7 |
157
+ | 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) — 서버 개시는 `broadcast(name,data)`, 클라 메시지 응답은 `ctx.broadcast` | §7 |
158
158
  | 페이지와 무관한 데이터 요청 (자동완성·옵션 조회 등 앱 내부용) | **JSON 액션 + `api()` 클라이언트** (`agents/web.md`·`agents/frontend.md`) | E-3 |
159
159
  | 외부에 공개하는 API (모바일 앱·서드파티) | **별도 API 앱 + JWT 옵션** | §3, §7 |
160
160
 
@@ -200,7 +200,7 @@ gaon doctor # 정적 검사 24종 (§2.2)
200
200
  |---|---|
201
201
  | `gaon new <name>` | 프로젝트 스캐폴드 |
202
202
  | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**코드 변경 감시·재시작**) |
203
- | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** |
203
+ | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
204
204
  | `gaon g <type> <name>` | 스캐폴드: `auth`·`controller`·`model`·`page`·`job` |
205
205
  | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
206
206
  | `gaon check` / `test` / `doctor` | 검증 루프 |
@@ -217,6 +217,10 @@ gaon doctor # 정적 검사 24종 (§2.2)
217
217
  개발 중이면 `gaon dev`(감시·재시작·`.gaon` 재생성 통합)를 쓴다. `serve` 는
218
218
  비-production 부팅 시 이 안내를 한 줄 출력한다.
219
219
 
220
+ **스케일링 구분** — `serve --workers N`(한 포트 · node:cluster 수직) vs 웹 인스턴스
221
+ 여러 대(각각 다른 `PORT` · 허브 뒤 수평) vs `work`(포트 없음 · 프로세스만)는 서로
222
+ 다르다. 로컬 멀티 인스턴스 레시피·구분표는 `docs/guides/operations.md` "스케일링" 절 참고.
223
+
220
224
  ## 5. npm 배포본 — 패키지 → 역할
221
225
 
222
226
  개발자는 파사드 **`gaonjs`** 하나만 설치한다(CLI 명령 `gaon`). 아래는 내부 패키지의 역할 지도다.
@@ -39,9 +39,15 @@ export default channel({
39
39
  onJoin(ctx) {
40
40
  ctx.broadcast({ type: 'joined', member: ctx.member })
41
41
  },
42
- // 클라이언트 메시지 수신
42
+ // 클라이언트 메시지 수신 — raw data 를 그대로 되쏘지 않는다(작성자 위조·XSS 표면).
43
43
  async onMessage(ctx, data) {
44
- ctx.broadcast(data) // 서버의 연결로 팬아웃
44
+ // 본문만 검증·상한해 취하고, 작성자는 서버 권위(ctx.user)로 붙인다 —
45
+ // 클라가 보낸 author/id 는 신뢰하지 않는다(§정본 예시 chatMessages.ts).
46
+ const raw = (data as { text?: unknown }).text
47
+ const text = typeof raw === 'string' ? raw.trim().slice(0, 2000) : ''
48
+ if (!text) return // 빈/비정상 메시지는 흘리지 않는다
49
+ const u = ctx.user as { id: bigint; name: string } | null
50
+ ctx.broadcast({ text, author: u ? { id: String(u.id), name: u.name } : null })
45
51
  },
46
52
  // 이탈 (연결 종료·프레즌스 해제 후)
47
53
  onLeave(ctx) {
@@ -74,6 +80,37 @@ export default channel({
74
80
 
75
81
  멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
76
82
 
83
+ ### 2.5 서버 개시 broadcast (결정 126)
84
+
85
+ `ctx.broadcast` 는 클라이언트 연결 훅(`onJoin`/`onMessage`/`onLeave`) **안에서만** 쓸 수 있다 —
86
+ 클라 메시지가 있어야 도는 경로다. **컨트롤러·서비스·잡처럼 서버가 클라 메시지 없이 채널을 밀
87
+ 때는 `gaonjs/async` 의 `broadcast(name, data)`** 를 쓴다 — 이름으로 채널을 지목해 전 서버·전
88
+ 구독자에게 발화한다(여러 인스턴스 자동 팬아웃 · errata E-2).
89
+
90
+ ```ts
91
+ // apps/web/controllers/posts.ts — service·job·listener 어디서든 동일하게 호출
92
+ import { controller } from 'gaonjs/web'
93
+ import { broadcast } from 'gaonjs/async'
94
+
95
+ export default controller({
96
+ async create() {
97
+ const post = await Post.create(this.params(Post.form))
98
+ broadcast('feed', { type: 'new-post', id: String(post.id) }) // 클라 메시지 불필요
99
+ return { id: post.id }
100
+ },
101
+ })
102
+ ```
103
+
104
+ - **`authorize` 재실행 없음** — `authorize` 는 **구독(연결 수립) 시점** 게이트다(§2). 서버 발화
105
+ broadcast 는 authorize 를 **다시 실행하지 않는다** — 이미 접속(= 인가 통과)한 구독자만 받고,
106
+ broadcast 는 신뢰된 서버 코드가 그 대상에게 미는 행위다. 특정 사용자에게만 보내야 하면 **채널을
107
+ 그렇게 분리**(예: `authorize` 로 소유자만 입장)하고 그 채널로 broadcast 한다.
108
+ - **payload = `unknown`**(자유 JSON) · 클라 `useChannel` 이 `{ t:'msg', data }` 로 받는다.
109
+ - **seal(결정 121)** — 재봉인은 각 수신 서버의 소켓 경계에서 일어난다. broadcast 는 기존 전달
110
+ 경로를 그대로 재사용하므로 seal 앱에서도 봉인된다(추가 처리 불필요).
111
+ - **realtime(NATS) 미설정 앱**에서 호출하면 수리 안내와 함께 throw · **구독자 없음**이면 조용히
112
+ 아무 데도 안 간다(fire-and-forget · 예외 아님).
113
+
77
114
  ### 3. 프레즌스
78
115
 
79
116
  `ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
@@ -136,25 +173,53 @@ export function useRoom(roomId: number) {
136
173
 
137
174
  서버가 먼저 밀어주는 데이터(알림·채팅·접속자)는 채널/프레즌스가 정답
138
175
  경로다 (루트 데이터 경로 판단표 2행). 폴링 `api()` 루프로 흉내내지
139
- 않는다.
176
+ 않는다. 그 안에서 **누가 발화하느냐**로 표면이 갈린다:
177
+
178
+ | 발화 주체 | 표면 | 쓰는 곳 |
179
+ | --- | --- | --- |
180
+ | 클라 메시지에 응답 | `ctx.broadcast(data)` | 채널 훅 `onMessage`(클라 메시지 필요) |
181
+ | 서버가 단독으로 밀기 | `broadcast(name, data)` (`gaonjs/async`) | 컨트롤러·서비스·잡·리스너 (클라 메시지 없이 · 결정 126) |
140
182
 
141
183
  ```ts
142
- // apps/web/channels/chatMessages.ts — 파일명 camelCase
184
+ // apps/web/channels/chatMessages.ts — 파일명 camelCase · 클라 메시지 응답형
143
185
  import { channel } from 'gaonjs/async'
144
186
 
145
187
  export default channel({
146
188
  authorize(ctx) { return ctx.user != null },
147
189
  presenceInfo(ctx) { return { name: (ctx.user as { name: string } | null)?.name ?? '익명' } },
148
- async onMessage(ctx, data) { ctx.broadcast(data) },
190
+ async onMessage(ctx, data) {
191
+ // 1) 본문만 클라에서 취한다 — 검증·상한(신뢰 경계). raw data 통째 브로드캐스트 금지.
192
+ const raw = (data as { text?: unknown }).text
193
+ const text = typeof raw === 'string' ? raw.trim().slice(0, 2000) : ''
194
+ if (!text) return // 빈/비정상 메시지는 흘리지 않는다
195
+
196
+ // 2) 작성자는 **서버 권위** — ctx.user 로 못박는다(클라가 보낸 author/id 는 신뢰 금지).
197
+ const u = ctx.user as { id: bigint; name: string } | null
198
+
199
+ // 3) 필요하면 여기서 저장한다(영속·조회): 예) await Message.create({ text, userId: u!.id })
200
+
201
+ // 4) 서버가 조립한 안전한 봉투만 팬아웃한다.
202
+ ctx.broadcast({ text, author: u ? { id: String(u.id), name: u.name } : null })
203
+ },
149
204
  })
150
205
  ```
151
206
 
207
+ 클라가 보낸 페이로드를 통째로 `ctx.broadcast(data)` 로 되쏘는 건 **반정본**이다 — 작성자
208
+ 위조·XSS 표면이 열린다. 본문만 검증·상한하고, 작성자·타임스탬프 같은 신뢰 필드는 서버가 붙인다.
209
+
210
+ 서버 개시(HTTP 요청·잡 처리 결과 등)로 미는 경우는 §2.5 `broadcast(name, data)` 를 쓴다 —
211
+ "flash 로 클라에 심고 클라가 다시 채널로 중계" 같은 우회는 **반정본**이다(탭 닫힘에 구멍 · 결정 126).
212
+
152
213
  ## 알려진 함정
153
214
 
154
215
  - **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (채널은
155
216
  앱 소속 · 라우트처럼 앱 경계 안).
156
217
  - **`presenceInfo` 에 민감 정보 금지** — 접속자 목록은 채널 전원에게
157
218
  공개된다. 공개 메타만.
219
+ - **raw data 에코는 반정본** — `onMessage(ctx, data) { ctx.broadcast(data) }` 처럼
220
+ 클라 페이로드를 통째로 되쏘면 작성자 위조·XSS 표면이 열린다. 본문만 검증·상한해
221
+ 취하고, 작성자 같은 신뢰 필드는 **서버 권위 `ctx.user`** 로 붙인 봉투만 broadcast
222
+ 한다(§2 · 정본 예시 chatMessages.ts).
158
223
  - **웹서버 ↔ 허브를 NATS 로 잇지 않는다** — TCP 지속 연결이 정본
159
224
  (E-2). NATS 는 broadcast 팬아웃 전용.
160
225
  - **서버 푸시 데이터를 `api()` 폴링으로 대체 금지** — 데이터 경로
@@ -170,6 +235,7 @@ export default channel({
170
235
  |---|---|
171
236
  | E-2 | 웹서버 ↔ 허브 = TCP 지속 연결 · NATS = broadcast 전용 |
172
237
  | §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
238
+ | 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
173
239
 
174
240
  ## `@gaonjs/seal` 켠 앱의 채널
175
241
 
@@ -26,6 +26,26 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
26
26
  > 그런 판단을 하는 순간 seal 은 보안을 **낮춘다**. `gaon doctor` 의 `seal-security` 가
27
27
  > seal 앱에서 rate limit·보안 헤더·CORS 를 명시적으로 끈 경우를 경고한다.
28
28
 
29
+ ### 은닉의 한계 — 여기까지다 (역공학 실측 · Kerckhoffs)
30
+
31
+ 불투명 export·non-literal 시드·미끼 시크릿에도 불구하고, 89KB wasm 은 디스어셈블(wasm2wat·Ghidra)로
32
+ **알고리즘·시드 파생·개봉 로직이 전부 복원**되고, 공격자는 `sealHttp`/`sealWs` 를 직접 호출해 조작
33
+ 데이터를 **올바르게 봉인**할 수 있다. 은닉은 **캐주얼/자동 티어 비용 상승까지만**이며 작정한 공격자는
34
+ 못 막는다. 이유는 구조적이다:
35
+
36
+ 1. **알고리즘은 숨길 수 없다** — 클라이언트에서 실제로 돌아야 하니 바이너리에 있다.
37
+ 2. **시드 입력이 전부 공격자 통제·공지 값이다** — `domain`·`path`·`uaSlice`·`timestamp` 는 요청에서 나온다.
38
+ 3. **진짜 비밀 키가 없다** — masterSecret 은 미끼(공개 전제 · 결정 121·ADR-056).
39
+
40
+ 따라서 보안은 **알고리즘 비밀이 아니라 키 비밀에 있어야 한다(Kerckhoffs)**. 조작·위조를 실제로 막는
41
+ 것은 **서버측 검증**(스키마·인가·CSRF·rate limit)과 **replay 방어**(nonce+drift)·**HTTPS** 이며, seal 은
42
+ 이를 대체하지 않는다.
43
+
44
+ > **"보안 강화" 명목으로 난독화를 더 쌓지 말 것 (security-through-obscurity · 기각).** "시드 포맷 추가 은닉 / 바이트 체인
45
+ > 추가 / 알고리즘 커스터마이즈"는 실익 0(security-through-obscurity)이라 **하지 않는다.** wasm strings
46
+ > 하드닝(자작 알고리즘 문자열·홈경로 제거 · §4)만 실효였고 그것만 했다. 알고리즘 은닉을 더 추격하는 것
47
+ > 자체가 이 원칙 위반이다.
48
+
29
49
  ### 막는 것 / 못 막는 것 경계표
30
50
 
31
51
  | 위협 | seal 이 막나 | 진짜 방어 |
@@ -73,13 +93,17 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
73
93
  - **요청/응답 JSON**: 클라 `installClientSeal()` 이 Inertia XHR 인터셉터(`XMLHttpRequest.prototype`) +
74
94
  `api()`/`fetch` 봉인을 설치한다. 서버는 Fastify **4-stage 훅**(`plugin.ts` · onRequest 분류/fail-closed →
75
95
  preParsing body 개봉+replay → preValidation query `?q=` 개봉+replay → onSend 응답 봉인)으로 대칭 복호.
76
- **JSON-intent 판별**: `Accept`/`Content-Type: application/json` 요청만 봉인 강제(비-JSON HTML·form 은 자동 면제).
96
+ **봉인 대상 판별 (결정 125)**: `Accept`/`Content-Type: application/json` **또는** `X-Inertia: true`(Inertia
97
+ 방문·네비게이션) 요청을 봉인 강제한다. Inertia GET 네비게이션은 `Accept: text/html` + `X-Inertia:true` 로
98
+ 와서 application/json 이 없으므로, 이 헤더까지 봐야 네비게이션 props 가 평문으로 새지 않는다(결정 125 P0).
99
+ 네이티브 브라우저 form·정적 자산·HTML 직접 로드는 셋 다 없어 자동 면제된다.
77
100
  - **최초 문서 data-page**: 서버가 `<script data-page="app" data-gaon-sealed="1">` 로 봉인 + `<meta gaon-seal-ts>`.
78
101
  클라 `createGaonApp` 이 Inertia 마운트 **전**에 wasm 으로 개봉 → 소스 보기·개발자도구에 평문 props 미노출.
79
102
  - **WS 프레임 (결정 124 · §3.4)**: `useChannel` 이 `setWsFrameCodec`(seal 클라 `wsEncode`/`wsDecode`)로
80
103
  채널 송수신을 봉인한다. 송신 `E:<ts>:<base64>` · seal namespace 는 평문 `P:` 프레임 **거부**(requireDecrypt · 결정 121).
81
- - **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비-JSON **자동 제외**(사람 판단 없이
82
- Content-Type 기계 판별). 외부(웹훅 등)가 봉인을 모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
104
+ - **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON Inertia 아닌 HTML
105
+ 직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
106
+ 모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
83
107
  - **fail-closed (403 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
84
108
  **403 SealError** — 평문 통과 절대 없음. WS 개봉 실패는 소켓 4500 종료(silent fallback 없음).
85
109
 
@@ -98,6 +122,14 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
98
122
  wasm 바이너리 내장**(JS 번들 미노출). **domain·user-agent 는 wasm 이 브라우저(web-sys)에서 직접 읽고**
99
123
  path 는 넘겨받은 URL 에서 wasm 이 파싱한다 — JS 소스에 "무엇이 키 유도 입력인가" 힌트를 남기지 않는다.
100
124
  (위조는 서버가 실 요청 헤더로 독립 유도해 이미 막힌다 · 이 은닉은 힌트 제거·공격 비용 상승 목적.)
125
+ - **wasm strings 하드닝 — 여기까지가 실효**: 빌드 산출 wasm 은 상주 게이트
126
+ (`wasm-hardening.test.ts`)로 ① JS 표면 불투명(고수준 4함수만) ② **자작 알고리즘 문자열 0**(에러/expect
127
+ 라벨에 `aes-gcm`·`base64 decode`·`HMAC` 등 미노출 · `.map_err(|_| "seal …")` 로 중립화) ③ **홈경로(PII) 0**
128
+ (`--remap-path-prefix=$HOME=/` · 이전엔 빌드 머신 사용자명이 박혔다)을 강제한다. **잔존(의도)**: `panic = "abort"`
129
+ 로도 **의존 crate 패닉 위치 경로**(`aes-0.8.4`·`sha2-0.10.9`·`base64-0.22.1` 등)는 안 지워져 `strings` 로
130
+ 알고리즘 **계열**은 여전히 샌다. **완전 차단은 나이틀리 `build-std`+`panic_immediate_abort` 세금이 필요해
131
+ 미채택** — 알고리즘은 비밀이 아니므로(§0 Kerckhoffs) 그 비용을 무는 것 자체가 obscurity 추격이다. 게이트는
132
+ "알고리즘 계열 0" 이 아니라 "자작 문자열 0 + 홈경로 0 + 표면 불투명" 으로 정의된다.
101
133
  - **서버는 wasm 이 아니다** — JS mirror(`crypto.ts`)로 봉인/개봉하고, Rust 정본(wasm)과 **known-vector parity**
102
134
  (`wasm-parity` 테스트)로 byte 호환을 강제한다.
103
135
  - **알고리즘**: AES-256-GCM(12-byte nonce · 16-byte tag) + nibble-swap XOR(0x5A) + base64 · 키 유도 =
@@ -115,6 +147,11 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
115
147
  경유 봉인 broadcast).
116
148
  - **목업 e2e 로 대체 금지.** 서버 inject·단위·wasm-parity 는 byte 호환을 잠글 뿐 "실 브라우저에서 마운트되나"를
117
149
  못 본다 — 원 wave 가 실-브라우저 e2e 를 미뤄 P0 3건(번들 불가·CSP 차단·Inertia 인터셉터 파손)을 놓친 교훈(결정 124).
150
+ - **봉인 검증은 네트워크 날 바디로만 봐야 한다 (검증법 함정).** seal 앱에서 `page.evaluate(fetch(...))` 나 렌더된
151
+ DOM 으로 봉인을 확인하면 **안 된다** — 클라 인터셉터가 fetch/XHR 응답을 **자동 개봉**하고 화면 DOM 은 개봉된
152
+ 평문이라, 봉인돼 있어도 평문으로 보인다(거짓 음성). 봉인은 **네트워크 계층의 날 응답 바디**
153
+ (Playwright `page.waitForResponse(...).text()`·`page.on('response')` · 외부 `curl`)로 **시그널 헤더 + 암호문**을
154
+ 직접 봐야 드러난다. 정본 게이트 ⑨(결정 125)가 이 방식으로 네비게이션 wire 봉인을 정면 단언한다.
118
155
 
119
156
  ## 알려진 함정
120
157
 
@@ -129,3 +166,4 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
129
166
 
130
167
  - **결정 121** — `@gaonjs/seal` 신설(GSP 이식 · 인터셉터 설계 · 앱 토글 · WS requireDecrypt · 기각 대안 4건).
131
168
  - **결정 124** — §3.1 개정: **app-side 정적 주입**(변수 동적 import 폐기) · **wasm 표면 은닉**(불투명 함수 · domain/ua/path 를 wasm 이 확보 · 미끼 시크릿 내장) · **WS 클라 봉인**(`setWsFrameCodec`) · **seal 앱 한정 CSP** · doctor `seal-security` main.ts 배선 검사 + `seal-client-wiring` fixer · 실 브라우저 e2e 게이트 · 부수 정정(`.wasm` MIME · `session.csrf` forwarding).
169
+ - **결정 125** — **Inertia 네비게이션 평문 P0** 수정: 봉인 대상 판별기(`isSealTarget`)에 `X-Inertia: true` 를 편입. Inertia GET 방문은 `Accept: text/html` 로 와 application/json 이 없어 자동 면제되던 탓에 응답 props 가 평문으로 새어나갔다(클라 인터셉터는 시그널을 붙였으나 서버가 봉인 안 함). 네비게이션 봉인 e2e 를 seal blocking 게이트에 편입(실 vite+chromium · wire 봉인/`?q=` 왕복 단언).
@@ -88,6 +88,11 @@
88
88
  작동한다. 토큰·다이제스트·개인정보 컬럼은 선언 시점에 hidden.
89
89
  - **`presenceInfo`** — 접속자 목록은 채널 전원에게 공개된다. 공개 메타만
90
90
  (`agents/realtime.md`).
91
+ - **서버 개시 `broadcast(name, data)` 는 `authorize` 를 재실행하지 않는다** (결정 126) —
92
+ 채널 `authorize` 는 **구독(연결) 시점** 게이트다. 서버 발화 broadcast 는 이미 접속(= 인가
93
+ 통과)한 구독자에게만 도달하고 authorize 를 다시 돌리지 않는다. 수신 대상을 제한하려면
94
+ **채널을 그렇게 분리**(`authorize` 로 소유자·역할만 입장)하고 그 채널로 broadcast 한다 —
95
+ broadcast 자체에 대상 필터는 없다(`agents/realtime.md` §2.5).
91
96
 
92
97
  ### 6. 클라이언트 IP · 프록시 신뢰 (결정 120)
93
98
 
@@ -9,6 +9,10 @@
9
9
  // import.meta.glob 으로 만든다. 심볼 출처가 코드에 그대로 보인다.
10
10
  import { createGaonApp } from 'gaonjs/vue'
11
11
 
12
+ // api() 런타임 매니페스트(결정 127) — 라우트 키 → { method, URL }. gaon dev·check·gen 이
13
+ // .gaon/routes.manifest.ts 를 재생성한다. 이 값을 넘겨야 api() 가 요청 경로를 안다.
14
+ import { routes } from './.gaon/routes.manifest.js'
15
+
12
16
  // 전역 스타일 — Tailwind 레이어 + 디자인 토큰(결정 74). 부수효과 import 라
13
17
  // 번들에 CSS 가 실린다. 앱마다 하나(관례 = 배치).
14
18
  import './style.css'
@@ -24,5 +28,6 @@ const layouts = import.meta.glob('./layouts/*.vue', { eager: true })
24
28
  void createGaonApp({
25
29
  pages,
26
30
  layouts,
31
+ routes,
27
32
  title: (t) => (t ? `${t} · {{PROJECT_NAME}}` : '{{PROJECT_NAME}}'),
28
33
  })
@@ -3,6 +3,12 @@
3
3
  #
4
4
  # 기동: docker compose up -d (gaon dev 가 자동 실행)
5
5
  # 정지: docker compose down
6
+ #
7
+ # 한 머신에서 gaon 프로젝트를 **여러 개** 동시에 개발할 때: `name:` 이 프로젝트별로 고유해
8
+ # 컨테이너·볼륨·네트워크는 서로 섞이지 않는다(다른 프로젝트 컨테이너를 recreate 하지 않음).
9
+ # 다만 아래 **호스트 포트**(5432·6379·4222 …)는 머신에 하나뿐이라 두 스택을 동시에 띄우면
10
+ # 충돌한다. 그럴 땐 한쪽에서 왼쪽(호스트) 포트만 바꾸고(예: "5433:5432"), 그 프로젝트의
11
+ # `gaon.config.ts` 접속 URL 포트도 같은 값으로 맞춘다. 오른쪽(컨테이너) 포트는 그대로 둔다.
6
12
  name: {{PROJECT_NAME}}
7
13
 
8
14
  services:
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "dev": "gaon dev",
12
- "build": "vite build",
12
+ "build": "gaon gen && vite build",
13
13
  "serve": "gaon serve",
14
14
  "work": "gaon work",
15
15
  "hub": "gaon hub",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.28.0",
3
+ "version": "0.30.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/data": "0.13.1",
31
- "@gaonjs/async": "0.6.1",
30
+ "@gaonjs/config": "0.9.2",
32
31
  "@gaonjs/core": "0.2.1",
33
- "@gaonjs/web": "0.11.0",
34
- "@gaonjs/config": "0.9.0",
35
- "@gaonjs/mail": "0.1.3"
32
+ "@gaonjs/web": "0.13.0",
33
+ "@gaonjs/data": "0.13.1",
34
+ "@gaonjs/mail": "0.1.3",
35
+ "@gaonjs/async": "0.7.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})\""