@gaonjs/cli 0.29.0 → 0.31.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) {
@@ -155,8 +155,14 @@ export async function runTestCommand(args = [], opts = {}) {
155
155
  cmd = vitestBin;
156
156
  spawnArgs = ['run', ...passthrough];
157
157
  }
158
+ // 결정 130: 테스트 프로세스에 스트림 네임스페이스 접두를 주입해 잡·이벤트
159
+ // NATS 스트림을 개발 스택과 격리한다(규칙 10 · DB `<db>_test` 와 대칭). 접두가
160
+ // 붙으면 테스트가 쓰는 스트림/subject 가 `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로
161
+ // 갈려, 개발용 `gaon work` 가 떠 있어도 서로 잡을 훔치지 않는다. 사용자가 이미
162
+ // 값을 세팅했으면 존중한다(고급 · 다중 테스트 컨텍스트 분리).
163
+ const testEnv = { ...process.env, GAON_STREAM_PREFIX: process.env.GAON_STREAM_PREFIX ?? 'test' };
158
164
  if (json) {
159
- process.stdout.write(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope }) + '\n');
165
+ process.stdout.write(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope, streamPrefix: testEnv.GAON_STREAM_PREFIX }) + '\n');
160
166
  }
161
167
  else {
162
168
  process.stdout.write(` gaon test · ${cmd} ${spawnArgs.join(' ')} (scope=${scope})\n`);
@@ -164,7 +170,7 @@ export async function runTestCommand(args = [], opts = {}) {
164
170
  const exitCode = await new Promise((resolvePromise) => {
165
171
  const child = spawn(cmd, spawnArgs, {
166
172
  cwd,
167
- env: process.env,
173
+ env: testEnv,
168
174
  // vitest 컬러 출력·리포터를 그대로 보여주기 위해 stdio 를 상속한다.
169
175
  stdio: 'inherit',
170
176
  });
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} 에서 실행 중입니다.`)
@@ -86,6 +86,10 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
86
86
  `curve`(백오프 곡선 ms) · `jitter` · `concurrency`.
87
87
  - **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
88
88
  `gaon jobs retry <id>` 로 조회·재적재한다.
89
+ - **실행 컨텍스트(결정 129)** — `gaon work` 는 `gaon serve` 와 **같은 도메인
90
+ 배선**(DB 커넥션 · 메일)을 태운다. 그래서 잡 핸들러에서 모델 조회(`User.find`),
91
+ `Mail.deliver`, `broadcast`, 다른 잡 `.later()` 를 웹 컨트롤러에서와 똑같이 쓴다
92
+ — 워커라서 못 쓰는 배터리는 없다.
89
93
  - 기본 백오프 곡선은 `[1s, 5s, 30s, 5m, 1h]` + 지터 0.2, 재시도 3
90
94
  (총 4시도) — §7 · 벤치마크로 확정된 값.
91
95
 
@@ -361,8 +361,11 @@ async function runSearch(q: string) {
361
361
  **method-override** 가 잡는다(결정 89).
362
362
  - **채널 구독은 `useChannel()`** — 실시간 클라이언트는 `gaonjs/vue` 의
363
363
  `useChannel(name, opts)` 가 정본(결정 87). `new WebSocket` 을 손으로 짜면
364
- URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클을 재구현하다
365
- 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에.
364
+ URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클·**자동 재연결**을
365
+ 재구현하다 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에. 소켓이 끊기면
366
+ useChannel 이 지수 백오프로 **자동 재접속**하고 프레즌스를 재동기한다(결정 128 · 기본
367
+ 켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401 은
368
+ 재연결 안 함). 손 WebSocket 재연결 루프를 짜지 말 것.
366
369
  - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
367
370
  중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
368
371
  - **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
@@ -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) {
@@ -138,6 +144,7 @@ export function useRoom(roomId: number) {
138
144
  params: { room: roomId },
139
145
  onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
140
146
  onPresence: (delta) => { /* 접속자 join/leave */ },
147
+ onReconnect: () => { /* 재연결됨 — 놓친 데이터를 Inertia partial reload 로 따라잡기 */ },
141
148
  })
142
149
  return { messages, status, send }
143
150
  }
@@ -147,8 +154,18 @@ export function useRoom(roomId: number) {
147
154
  정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
148
155
  - **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출, `presence` 는
149
156
  `onPresence`. 그 외는 `onFrame`.
157
+ - **자동 재연결(결정 128 · 기본 켬)** — 소켓이 끊기면(서버 재시작·네트워크 blip)
158
+ useChannel 이 **지수 백오프**(1s·2s·5s·10s · 이후 10s 반복 · 지터)로 자동 재접속한다.
159
+ 새 소켓은 서버가 다시 인가하고 프레즌스 스냅샷을 다시 밀어주므로 접속자 목록이
160
+ 재동기된다(`messages` 는 유지 · 놓친 이벤트 리플레이는 범위 밖). `status` 는
161
+ `'connecting' | 'open' | 'reconnecting' | 'closed'` 로, 드롭 후 `'reconnecting'`,
162
+ 성공하면 `'open'`. **재연결 성공 시 `onReconnect` 콜백**으로 놓친 데이터를 따라잡는다
163
+ (Inertia partial reload). **미인가(4401 close · authorize 거부·세션 만료)면 재연결하지
164
+ 않고 `'closed'`** — 서버 다운(재시도)과 인가 거부(포기)를 close code 로 가른다. 끄려면
165
+ `reconnect: false`, 튜닝은 `reconnect: { curveMs, maxDelayMs, maxAttempts }`. 언마운트·
166
+ 수동 `close()` = 의도적 종료라 재연결하지 않는다.
150
167
  - **탈출구** — 표준 WebSocket 이 필요하면 `new WebSocket('<프리픽스>/gaon/ws/<채널명>')`
151
- 을 직접 쓸 수 있다(봉투·라이프사이클을 스스로 책임진다). 기본 경로는 `useChannel`.
168
+ 을 직접 쓸 수 있다(봉투·라이프사이클·재연결을 스스로 책임진다). 기본 경로는 `useChannel`.
152
169
 
153
170
  ### 5. 허브 프로세스 (`gaon hub`)
154
171
 
@@ -181,10 +198,26 @@ import { channel } from 'gaonjs/async'
181
198
  export default channel({
182
199
  authorize(ctx) { return ctx.user != null },
183
200
  presenceInfo(ctx) { return { name: (ctx.user as { name: string } | null)?.name ?? '익명' } },
184
- async onMessage(ctx, data) { ctx.broadcast(data) },
201
+ async onMessage(ctx, data) {
202
+ // 1) 본문만 클라에서 취한다 — 검증·상한(신뢰 경계). raw data 통째 브로드캐스트 금지.
203
+ const raw = (data as { text?: unknown }).text
204
+ const text = typeof raw === 'string' ? raw.trim().slice(0, 2000) : ''
205
+ if (!text) return // 빈/비정상 메시지는 흘리지 않는다
206
+
207
+ // 2) 작성자는 **서버 권위** — ctx.user 로 못박는다(클라가 보낸 author/id 는 신뢰 금지).
208
+ const u = ctx.user as { id: bigint; name: string } | null
209
+
210
+ // 3) 필요하면 여기서 저장한다(영속·조회): 예) await Message.create({ text, userId: u!.id })
211
+
212
+ // 4) 서버가 조립한 안전한 봉투만 팬아웃한다.
213
+ ctx.broadcast({ text, author: u ? { id: String(u.id), name: u.name } : null })
214
+ },
185
215
  })
186
216
  ```
187
217
 
218
+ 클라가 보낸 페이로드를 통째로 `ctx.broadcast(data)` 로 되쏘는 건 **반정본**이다 — 작성자
219
+ 위조·XSS 표면이 열린다. 본문만 검증·상한하고, 작성자·타임스탬프 같은 신뢰 필드는 서버가 붙인다.
220
+
188
221
  서버 개시(HTTP 요청·잡 처리 결과 등)로 미는 경우는 §2.5 `broadcast(name, data)` 를 쓴다 —
189
222
  "flash 로 클라에 심고 클라가 다시 채널로 중계" 같은 우회는 **반정본**이다(탭 닫힘에 구멍 · 결정 126).
190
223
 
@@ -194,6 +227,10 @@ export default channel({
194
227
  앱 소속 · 라우트처럼 앱 경계 안).
195
228
  - **`presenceInfo` 에 민감 정보 금지** — 접속자 목록은 채널 전원에게
196
229
  공개된다. 공개 메타만.
230
+ - **raw data 에코는 반정본** — `onMessage(ctx, data) { ctx.broadcast(data) }` 처럼
231
+ 클라 페이로드를 통째로 되쏘면 작성자 위조·XSS 표면이 열린다. 본문만 검증·상한해
232
+ 취하고, 작성자 같은 신뢰 필드는 **서버 권위 `ctx.user`** 로 붙인 봉투만 broadcast
233
+ 한다(§2 · 정본 예시 chatMessages.ts).
197
234
  - **웹서버 ↔ 허브를 NATS 로 잇지 않는다** — TCP 지속 연결이 정본
198
235
  (E-2). NATS 는 broadcast 팬아웃 전용.
199
236
  - **서버 푸시 데이터를 `api()` 폴링으로 대체 금지** — 데이터 경로
@@ -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 이 막나 | 진짜 방어 |
@@ -102,6 +122,14 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
102
122
  wasm 바이너리 내장**(JS 번들 미노출). **domain·user-agent 는 wasm 이 브라우저(web-sys)에서 직접 읽고**
103
123
  path 는 넘겨받은 URL 에서 wasm 이 파싱한다 — JS 소스에 "무엇이 키 유도 입력인가" 힌트를 남기지 않는다.
104
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 + 표면 불투명" 으로 정의된다.
105
133
  - **서버는 wasm 이 아니다** — JS mirror(`crypto.ts`)로 봉인/개봉하고, Rust 정본(wasm)과 **known-vector parity**
106
134
  (`wasm-parity` 테스트)로 byte 호환을 강제한다.
107
135
  - **알고리즘**: AES-256-GCM(12-byte nonce · 16-byte tag) + nibble-swap XOR(0x5A) + base64 · 키 유도 =
@@ -119,6 +147,11 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
119
147
  경유 봉인 broadcast).
120
148
  - **목업 e2e 로 대체 금지.** 서버 inject·단위·wasm-parity 는 byte 호환을 잠글 뿐 "실 브라우저에서 마운트되나"를
121
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 봉인을 정면 단언한다.
122
155
 
123
156
  ## 알려진 함정
124
157
 
@@ -15,13 +15,19 @@
15
15
  테스트만 다른 DB 로 돌리는 것은 자기모순이며, 방언 차이 버그가
16
16
  테스트를 통과해 운영에서 터지는 구멍이었다.
17
17
 
18
- - 테스트 전에 compose 로 DB · NATS 를 띄우고, **테스트 전용
19
- 데이터베이스**(truncate 격리 · 결정 111) + **테스트 전용 스트림
20
- 프리픽스**를 쓴다.
18
+ - 테스트 전에 compose 로 DB · NATS 를 띄운다. `gaon test` 가 **테스트 전용
19
+ 데이터베이스**(`<db>_test` · truncate 격리 · 결정 111) **테스트 전용 NATS
20
+ 스트림 네임스페이스**(`GAON_STREAM_PREFIX` · 결정 130) 를 **자동으로** 격리한다.
21
21
  - `gaon test` 가 테스트 전용 데이터베이스(`<db>_test`)를 **자동 준비**한다
22
22
  — 없으면 만들고(CREATE DATABASE) 마이그레이션까지 적용한 뒤 vitest 를
23
23
  돌린다. 스캐폴드 `test/setup.ts` 가 그 DB 에 붙고(`connectTestDatabase`)
24
24
  매 테스트 뒤 전 테이블을 비운다(`truncateAll`). 아래 §5 참고.
25
+ - `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
26
+ 테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
27
+ `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 그래서 개발용 `gaon work` 가
28
+ 떠 있어도 테스트 잡을 훔치지 않고, 테스트가 남긴 잡을 개발 워커가 처리하지도
29
+ 않는다 — **테스트 전에 워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이
30
+ 격리가 없어 개발 스택과 섞이니 `gaon test` 를 쓴다.)
25
31
  - SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
26
32
  아니다.
27
33
 
@@ -170,8 +176,10 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
170
176
  - **`expect(true).toBe(true)` 류 무의미 단언 금지** — 실 인프라에
171
177
  접속하지 않는 통합 테스트는 §9 위반으로 판정된다.
172
178
  - **NATS·DB 목업 금지** — `vi.mock` 으로 전송을 막으면 검증이 무효.
173
- - **테스트 격리** 공유 이름을 쓰면 병렬 테스트가 서로의 잡을
174
- 소비한다. 큐·스트림 프리픽스를 테스트 전용으로.
179
+ - **테스트 격리는 `gaon test` 준다(결정 130)** 잡·이벤트 스트림을
180
+ 테스트 네임스페이스(`GAON_STREAM_PREFIX`)로 자동 격리하므로, 개발용 `gaon work`
181
+ 와 나란히 돌려도 서로의 잡을 소비하지 않는다. 직접 `vitest` 로 돌리면 이 격리가
182
+ 없으니(개발 스택 공유) `gaon test` 를 쓴다.
175
183
  - **워커 정리** — 직접 `runWorker` 를 쓰면 테스트 종료 전 `stop()` 을
176
184
  보장하라 (`expectJobProcessed` 는 자동 정리).
177
185
  - **직렬화 테스트가 직접 행만 봄** — hidden 검증을 `Model.create()` 결과
@@ -185,5 +193,6 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
185
193
  | 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`gaonjs/testing`) |
186
194
  | 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
187
195
  | 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
196
+ | 결정 130 | `gaon test` 가 NATS 스트림도 자동 격리(`GAON_STREAM_PREFIX` → `GAON_TEST_JOBS`·`test.gaon.jobs.>`) — 개발 워커 병행 시 잡 누출 방지(규칙 10 이행) |
188
197
  | §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
189
198
  | 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |
@@ -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
  })
@@ -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/dist/work.js CHANGED
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import { hostname } from 'node:os';
13
13
  import { connectNats, runWork } from '@gaonjs/async';
14
- import { createDb } from '@gaonjs/data';
14
+ import { createDb, registerConnection, hasConnection, getConnection } from '@gaonjs/data';
15
+ import { loadGaonConfig, wireDomain } from '@gaonjs/config';
15
16
  import { loadDomain } from './domain.js';
16
17
  /** DB URL 에서 어댑터를 추정한다(postgres·mysql). */
17
18
  function dbConfigFromUrl(url) {
@@ -69,12 +70,23 @@ export async function runWorkCommand(opts = {}) {
69
70
  }
70
71
  };
71
72
  const nats = await connectNats({ servers: opts.natsUrl, name: `work@${id}` });
72
- let db;
73
+ // 도메인 배터리 배선(결정 129) — 잡 핸들러가 모델을 읽고(registerConnection)
74
+ // 메일을 보내려면(configureMailer) work 도 serve 와 같은 도메인 배선을 태워야
75
+ // 한다. serve 는 wireGaon → wireDomain, work 은 여기서 직접 wireDomain 을
76
+ // 호출한다. 이 배선이 없으면 잡에서 모델 조회·메일 발송이 운영 워커에서만
77
+ // 조용히 깨진다(gaon dev 는 serve 프로세스가 대신 배선해 가려짐).
78
+ const config = await loadGaonConfig(root);
79
+ const domainWiring = await wireDomain(config, root);
80
+ // 아웃박스 릴레이·모델 읽기용 main 커넥션. gaon.config 에 db.main 이 있으면
81
+ // wireDomain 이 이미 등록했다. config 없이 env(DATABASE_URL)만 있는 프로젝트는
82
+ // 여기서 관례로 보강 등록해 잡의 모델 읽기와 릴레이가 함께 동작하게 한다.
73
83
  // 스캐폴드 .env 관례(DATABASE_URL)를 정본으로 · GAON_DATABASE_URL 은 하위호환.
74
- // serve(config 경유 DATABASE_URL)와 work 가 같은 env 를 보게 한다.
75
84
  const dbUrl = opts.databaseUrl ?? process.env.DATABASE_URL ?? process.env.GAON_DATABASE_URL;
76
- if (dbUrl)
77
- db = createDb(dbConfigFromUrl(dbUrl));
85
+ if (!hasConnection('main') && dbUrl) {
86
+ const cfg = dbConfigFromUrl(dbUrl);
87
+ registerConnection('main', createDb(cfg), cfg.adapter);
88
+ }
89
+ const db = hasConnection('main') ? getConnection('main') : undefined;
78
90
  const domain = await loadDomain(root);
79
91
  // 로드된 도메인 자산 요약(파일=등록 관측용). 잡·리스너·메일 수를 노출한다.
80
92
  if (json) {
@@ -99,8 +111,7 @@ export async function runWorkCommand(opts = {}) {
99
111
  });
100
112
  }
101
113
  catch (err) {
102
- if (db)
103
- await db.destroy().catch(() => { });
114
+ await domainWiring.closeDomain().catch(() => { });
104
115
  await nats.close();
105
116
  throw err;
106
117
  }
@@ -110,8 +121,7 @@ export async function runWorkCommand(opts = {}) {
110
121
  signals.off('SIGTERM', stop);
111
122
  void (async () => {
112
123
  await work.stop();
113
- if (db)
114
- await db.destroy().catch(() => { });
124
+ await domainWiring.closeDomain().catch(() => { });
115
125
  await nats.close();
116
126
  if (json)
117
127
  process.stdout.write(JSON.stringify({ kind: 'stopped' }) + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.7.0",
31
- "@gaonjs/config": "0.9.1",
30
+ "@gaonjs/async": "0.8.0",
31
+ "@gaonjs/config": "0.10.0",
32
32
  "@gaonjs/data": "0.13.1",
33
+ "@gaonjs/core": "0.2.1",
33
34
  "@gaonjs/mail": "0.1.3",
34
- "@gaonjs/web": "0.12.0",
35
- "@gaonjs/core": "0.2.1"
35
+ "@gaonjs/web": "0.13.1"
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})\""