@gaonjs/cli 0.57.3 → 0.58.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.
@@ -1,10 +1,12 @@
1
1
  import { type ScaffoldFile, type WriteResult } from '../scaffold/index.js';
2
- export type GenerateType = 'controller' | 'model' | 'page' | 'job' | 'app';
2
+ export type GenerateType = 'controller' | 'model' | 'page' | 'job' | 'app' | 'channel';
3
3
  export interface GenerateOptions {
4
4
  readonly cwd?: string;
5
5
  readonly app?: string;
6
6
  readonly overwrite?: boolean;
7
7
  readonly json?: boolean;
8
+ /** 결정 442: `g channel --instance` — 파라미터화(인스턴스) 채널 변형(결정 440). */
9
+ readonly instance?: boolean;
8
10
  }
9
11
  export interface GenerateResult {
10
12
  readonly type: GenerateType;
@@ -19,8 +21,11 @@ export declare function parseGenerateArgs(argv: readonly string[]): {
19
21
  app: string | undefined;
20
22
  overwrite: boolean;
21
23
  json: boolean;
24
+ instance: boolean;
22
25
  };
23
26
  /** 타입에 따라 스캐폴드 파일 목록을 만든다. */
24
- export declare function planScaffold(type: GenerateType, name: string, app: string): ScaffoldFile[];
27
+ export declare function planScaffold(type: GenerateType, name: string, app: string, opts?: {
28
+ instance?: boolean;
29
+ }): ScaffoldFile[];
25
30
  /** `gaon g <type> <name>` 실행. exitCode 를 반환한다(0=성공, 1=실패). */
26
31
  export declare function runGenerateCommand(type: GenerateType, name: string, opts?: GenerateOptions): number;
@@ -16,7 +16,7 @@
16
16
  // job → domain/jobs/<camel>.ts
17
17
  import { existsSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
- import { appScaffoldFiles, controllerScaffold, inflectModel, jobScaffold, jobTestScaffold, modelScaffoldFiles, pageScaffold, writeScaffold, } from '../scaffold/index.js';
19
+ import { appScaffoldFiles, channelScaffoldFiles, controllerScaffold, inflectModel, jobScaffold, jobTestScaffold, modelScaffoldFiles, pageScaffold, writeScaffold, } from '../scaffold/index.js';
20
20
  import { appWiringFiles, readProjectName } from '../scaffold/app-wiring.js';
21
21
  /** argv 에서 옵션을 뽑는다(간단 파서 · runCli 관례와 일치). */
22
22
  export function parseGenerateArgs(argv) {
@@ -26,6 +26,7 @@ export function parseGenerateArgs(argv) {
26
26
  let app;
27
27
  let overwrite = false;
28
28
  let json = false;
29
+ let instance = false;
29
30
  for (let i = 1; i < argv.length; i++) {
30
31
  const a = argv[i];
31
32
  if (a === '--json') {
@@ -36,6 +37,10 @@ export function parseGenerateArgs(argv) {
36
37
  overwrite = true;
37
38
  continue;
38
39
  }
40
+ if (a === '--instance') {
41
+ instance = true;
42
+ continue;
43
+ }
39
44
  if (a === '--app') {
40
45
  app = argv[++i];
41
46
  continue;
@@ -45,7 +50,7 @@ export function parseGenerateArgs(argv) {
45
50
  if (name === undefined)
46
51
  name = a;
47
52
  }
48
- return { type, name, app, overwrite, json };
53
+ return { type, name, app, overwrite, json, instance };
49
54
  }
50
55
  /** 이름 검증 — 위험 문자(경로 탈출 등) 차단. */
51
56
  function validateName(name) {
@@ -58,7 +63,7 @@ function validateName(name) {
58
63
  // page 는 '/' 를 허용, 나머지는 슬래시 금지(g.ts 가 타입별로 재검사)
59
64
  }
60
65
  /** 타입에 따라 스캐폴드 파일 목록을 만든다. */
61
- export function planScaffold(type, name, app) {
66
+ export function planScaffold(type, name, app, opts = {}) {
62
67
  validateName(name);
63
68
  if (type === 'page') {
64
69
  return [pageScaffold(name, app)];
@@ -70,6 +75,9 @@ export function planScaffold(type, name, app) {
70
75
  if (name.includes('/')) {
71
76
  throw new Error(`${type} 이름에 '/' 는 사용할 수 없습니다: ${name}`);
72
77
  }
78
+ // 채널(결정 442): 정의 + 클라 컴포저블 짝 — --instance 는 파라미터화 변형(결정 440).
79
+ if (type === 'channel')
80
+ return channelScaffoldFiles(name, app, opts.instance === true);
73
81
  const names = inflectModel(name);
74
82
  if (type === 'controller')
75
83
  return [controllerScaffold(names, app)];
@@ -86,7 +94,7 @@ export function runGenerateCommand(type, name, opts = {}) {
86
94
  const app = opts.app ?? 'web';
87
95
  let files;
88
96
  try {
89
- files = planScaffold(type, name, app);
97
+ files = planScaffold(type, name, app, { instance: opts.instance });
90
98
  }
91
99
  catch (err) {
92
100
  const msg = err instanceof Error ? err.message : String(err);
@@ -128,7 +136,7 @@ export function runGenerateCommand(type, name, opts = {}) {
128
136
  const result = {
129
137
  type,
130
138
  name,
131
- app: type === 'controller' || type === 'page' ? app : null,
139
+ app: type === 'controller' || type === 'page' || type === 'channel' ? app : null,
132
140
  write,
133
141
  };
134
142
  if (opts.json) {
@@ -0,0 +1,7 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** 주석 제거 후 소스에 `instance: true` 선언이 있는지. */
3
+ export declare function declaresInstance(source: string): boolean;
4
+ /** 주석 제거 후 소스에 authorize 훅이 있는지(메서드·프로퍼티 두 표기 인정). */
5
+ export declare function hasAuthorize(source: string): boolean;
6
+ /** apps/<앱>/channels/ 를 훑어 authorize 없는 인스턴스 채널을 낸다. */
7
+ export declare function checkChannelInstanceAuthorize(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,88 @@
1
+ // @gaonjs/cli · doctor · 인스턴스 채널 authorize 검사 (결정 440)
2
+ //
3
+ // `instance: true` 채널은 임의 문자열 인스턴스 키로 무한 실행 인스턴스가
4
+ // 열린다(`/gaon/ws/<name>/<instance>`). authorize 가 없으면 **아무나 아무
5
+ // 인스턴스에나** 입장한다 — 매치·스레드처럼 참가자가 정해진 도메인에서는
6
+ // 인가 구멍이다. 다만 공개 관전형(누구나 아무 방을 구경) 설계도 실재하므로
7
+ // 하드 강제(4400/에러)가 아니라 **warn** 으로 둔다: 의도한 공개면 무시하고,
8
+ // 아니면 authorize(ctx) 에서 ctx.instance 로 입장을 판정하라.
9
+ //
10
+ // 판정은 소스 텍스트 기반(가벼운 정적 검사 · channel-collision 과 동일 접근).
11
+ // 재수출 파일(shared 공유 채널)은 대상 모듈을 따라가 정의 소스를 검사한다.
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join, relative, resolve, dirname } from 'node:path';
14
+ import { stripComments } from './source-scan.js';
15
+ import { reexportSpecifier } from './channel-collision.js';
16
+ /** 주석 제거 후 소스에 `instance: true` 선언이 있는지. */
17
+ export function declaresInstance(source) {
18
+ return /\binstance\s*:\s*true\b/.test(stripComments(source));
19
+ }
20
+ /** 주석 제거 후 소스에 authorize 훅이 있는지(메서드·프로퍼티 두 표기 인정). */
21
+ export function hasAuthorize(source) {
22
+ const src = stripComments(source);
23
+ return /\bauthorize\s*[(:]/.test(src);
24
+ }
25
+ /** apps/<앱>/channels/ 를 훑어 authorize 없는 인스턴스 채널을 낸다. */
26
+ export async function checkChannelInstanceAuthorize(cwd) {
27
+ const appsDir = join(cwd, 'apps');
28
+ const issues = [];
29
+ // 재수출 대상(shared 정의)은 여러 앱이 공유한다 — 정의 파일 기준으로 dedupe.
30
+ const reported = new Set();
31
+ for (const app of await safeListDirs(appsDir)) {
32
+ const chDir = join(appsDir, app, 'channels');
33
+ for (const file of (await safeListFiles(chDir)).sort()) {
34
+ if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
35
+ continue;
36
+ const abs = join(chDir, file);
37
+ const name = file.slice(0, -3);
38
+ let defAbs = abs;
39
+ let source = await readFile(abs, 'utf8').catch(() => '');
40
+ // 재수출이면 정의 모듈을 따라간다(상대 지정자만 — 패키지 지정자는 판정 불가라 원본 유지).
41
+ const spec = reexportSpecifier(source);
42
+ if (spec && spec.startsWith('.')) {
43
+ const target = resolve(dirname(abs), spec).replace(/\.js$/, '.ts');
44
+ const targetSrc = await readFile(target, 'utf8').catch(() => undefined);
45
+ if (targetSrc !== undefined) {
46
+ defAbs = target;
47
+ source = targetSrc;
48
+ }
49
+ }
50
+ if (!declaresInstance(source) || hasAuthorize(source))
51
+ continue;
52
+ if (reported.has(defAbs))
53
+ continue;
54
+ reported.add(defAbs);
55
+ const rel = relative(cwd, defAbs);
56
+ issues.push({
57
+ rule: 'channel-instance-authorize',
58
+ level: 'warning',
59
+ file: rel,
60
+ message: `인스턴스 채널 '${name}' 에 authorize 가 없습니다 — 누구나 아무 인스턴스` +
61
+ `(/gaon/ws/${name}/<아무 키>)에나 입장할 수 있습니다.\n` +
62
+ `→ 참가자가 정해진 채널(매치·스레드 등)이면 ${rel} 에 authorize 를 추가하고 ctx.instance 로 입장을 판정하세요:\n` +
63
+ ` authorize(ctx) { return isParticipant(ctx.user, ctx.instance) }\n` +
64
+ `→ 공개 관전형(누구나 입장)이 의도라면 이 경고는 무시해도 됩니다.`,
65
+ detail: { channel: name, file: rel },
66
+ });
67
+ }
68
+ }
69
+ return { rule: 'channel-instance-authorize', issues };
70
+ }
71
+ async function safeListDirs(dir) {
72
+ try {
73
+ const entries = await readdir(dir, { withFileTypes: true });
74
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
75
+ }
76
+ catch {
77
+ return [];
78
+ }
79
+ }
80
+ async function safeListFiles(dir) {
81
+ try {
82
+ const entries = await readdir(dir, { withFileTypes: true });
83
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ }
@@ -14,7 +14,7 @@ export declare const FIXERS: Partial<Record<DoctorRule, Fixer>>;
14
14
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
15
15
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
16
16
  *
17
- * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 30종을 **빠짐없이** 담는다 —
17
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 31종을 **빠짐없이** 담는다 —
18
18
  * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
19
19
  * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
20
20
  * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
@@ -28,7 +28,7 @@ export const FIXERS = {
28
28
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
29
29
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
30
30
  *
31
- * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 30종을 **빠짐없이** 담는다 —
31
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 31종을 **빠짐없이** 담는다 —
32
32
  * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
33
33
  * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
34
34
  * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
@@ -180,6 +180,11 @@ export const FIXER_CAPABILITIES = [
180
180
  hasFixer: false,
181
181
  note: '수동 · 앱마다 채널 이름을 분리(파일명 + 클라이언트 useChannel 인자 동시 변경)하거나, 의도적 공유면 정의를 shared/channels/ 하나로 옮기고 각 앱에서 재수출하세요 — 어느 쪽인지는 설계 판단이라 자동 정정하지 않습니다.',
182
182
  },
183
+ {
184
+ rule: 'channel-instance-authorize',
185
+ hasFixer: false,
186
+ note: '수동 · 참가자가 정해진 인스턴스 채널이면 authorize(ctx) 에서 ctx.instance 로 입장을 판정하세요 — 인가 규칙은 도메인 로직이라 자동 정정하지 않습니다. 공개 관전형이 의도면 경고를 무시해도 됩니다(결정 440).',
187
+ },
183
188
  {
184
189
  rule: 'dotenv-node-env',
185
190
  hasFixer: false,
@@ -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' | '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' | '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' | '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';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -25,13 +25,14 @@ export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-
25
25
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
26
26
  export { checkLocaleParity } from './doctor/locale-parity.js';
27
27
  export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
28
+ export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
28
29
  export { renderHuman, renderJson } from './doctor/reporter.js';
29
30
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
30
31
  /**
31
- * 실행할 검사 이름. 지정 없음(undefined) = 30개 모두.
32
+ * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
32
33
  */
33
34
  /**
34
- * doctor 정적 검사 30종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
35
+ * doctor 정적 검사 31종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
35
36
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
36
37
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
37
38
  */
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
- * 30 검사를 조립한다:
4
+ * 31 검사를 조립한다:
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 규칙)
@@ -31,7 +31,8 @@
31
31
  * 27) locale-parity (결정 216 · 13차 W4 · 로케일 간 키 부분 누락 = fallback 조용 노출 경고)
32
32
  * 28) render-return (결정 340 · this.render/redirect/json 호출만 하고 return 누락 = 무신호 204 경고)
33
33
  * 29) channel-collision (§7 · 앱간 동명 채널 = 전역 subject·프레즌스 병합 error)
34
- * 30) dotenv-node-env (결정 430 · 공유 .env NODE_ENV = 모드 누출 경고)
34
+ * 30) channel-instance-authorize (결정 440 · authorize 없는 인스턴스 채널 = 임의 인스턴스 공개 입장 경고)
35
+ * 31) dotenv-node-env (결정 430 · 공유 .env 의 NODE_ENV = 모드 누출 경고)
35
36
  *
36
37
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
37
38
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -73,6 +74,7 @@ import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
73
74
  import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
74
75
  import { checkLocaleParity } from './doctor/locale-parity.js';
75
76
  import { checkChannelCollision } from './doctor/channel-collision.js';
77
+ import { checkChannelInstanceAuthorize } from './doctor/channel-instance-authorize.js';
76
78
  import { checkDotenvNodeEnv } from './doctor/dotenv-node-env.js';
77
79
  import { renderHuman, renderJson } from './doctor/reporter.js';
78
80
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
@@ -102,13 +104,14 @@ export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-
102
104
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
103
105
  export { checkLocaleParity } from './doctor/locale-parity.js';
104
106
  export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
107
+ export { checkChannelInstanceAuthorize, declaresInstance, hasAuthorize, } from './doctor/channel-instance-authorize.js';
105
108
  export { renderHuman, renderJson } from './doctor/reporter.js';
106
109
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
107
110
  /**
108
- * 실행할 검사 이름. 지정 없음(undefined) = 30개 모두.
111
+ * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
109
112
  */
110
113
  /**
111
- * doctor 정적 검사 30종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
114
+ * doctor 정적 검사 31종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
112
115
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
113
116
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
114
117
  */
@@ -142,6 +145,7 @@ export const ALL_RULES = [
142
145
  'locale-parity',
143
146
  'render-return',
144
147
  'channel-collision',
148
+ 'channel-instance-authorize',
145
149
  'dotenv-node-env',
146
150
  ];
147
151
  /**
@@ -180,6 +184,7 @@ export const RULE_SUMMARIES = {
180
184
  'locale-parity': '로케일 커버리지',
181
185
  'render-return': 'render return 누락',
182
186
  'channel-collision': '앱간 동명 채널',
187
+ 'channel-instance-authorize': '인스턴스 채널 authorize',
183
188
  'dotenv-node-env': '.env NODE_ENV',
184
189
  };
185
190
  const CHECKERS = {
@@ -212,6 +217,7 @@ const CHECKERS = {
212
217
  'locale-parity': checkLocaleParity,
213
218
  'render-return': checkRenderReturn,
214
219
  'channel-collision': checkChannelCollision,
220
+ 'channel-instance-authorize': checkChannelInstanceAuthorize,
215
221
  'dotenv-node-env': checkDotenvNodeEnv,
216
222
  };
217
223
  /**
package/dist/generate.js CHANGED
@@ -16,6 +16,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { authUiKitFiles, writeUiKitFiles } from './uikit.js';
19
+ import { hasAppWiring } from './scaffold/app-wiring.js';
19
20
  /** 결정 155: 이 스캐폴드가 공개 회원가입을 포함하는가(web 기본 O · 비-web 은 --public 시만). */
20
21
  function includesPublicRegistration(opts) {
21
22
  const app = opts.app ?? 'web';
@@ -398,6 +399,28 @@ export function runGenerateAuthCommand(opts = {}) {
398
399
  process.stderr.write(` ✗ gaon g auth --jwt: --public 은 세션 스캐폴드 전용입니다 — JWT 변형은 회원가입을 깔지 않습니다(계정은 web 가입 또는 seed).\n`);
399
400
  return 1;
400
401
  }
402
+ // 결정 441: 세션(프론트) 변형은 대상 앱에 프론트 진입(index.html + Tailwind
403
+ // 배선)이 있어야 한다 — 없으면 로그인/대시보드 페이지가 마운트될 수 없는
404
+ // **항상 파손된 반쪽 앱**이 생긴다(부팅 불가·빌드 실패). 생성 전에 fail-loud
405
+ // 로 막고 정답 순서를 안내한다(§7.5.3). JWT 변형은 프론트가 없어 해당 없음 —
406
+ // 그 경로는 이 명령 하나가 앱 폴더째 만든다(결정 338).
407
+ if (!opts.jwt) {
408
+ const wired = hasAppWiring(cwd, app) && existsSync(join(cwd, 'apps', app, 'index.html'));
409
+ if (!wired) {
410
+ const fix = app === 'web'
411
+ ? `→ gaon new 로 만든 프로젝트 루트에서 실행하세요 — apps/web/{index.html,main.ts,style.css} 가 프론트 진입입니다.`
412
+ : `→ 먼저 \`gaon g app ${app}\` 으로 앱을 만든 뒤 \`gaon g auth --app ${app}\` 을 실행하세요.`;
413
+ const msg = `대상 앱 apps/${app}/ 에 프론트 진입(index.html·main.ts·style.css)이 없습니다 — ` +
414
+ `인증 페이지가 마운트될 수 없어 생성하지 않습니다.\n ${fix}`;
415
+ if (opts.json) {
416
+ process.stdout.write(JSON.stringify({ command: 'g auth', ok: false, app, error: msg }) + '\n');
417
+ }
418
+ else {
419
+ process.stderr.write(` ✗ gaon g auth: ${msg}\n`);
420
+ }
421
+ return 1;
422
+ }
423
+ }
401
424
  const result = writeAuthScaffold(cwd, { app, public: opts.public, jwt: opts.jwt });
402
425
  // 결정 361: 배선 미완(incomplete)은 성공이 아니다 — 이전엔 무조건 exit 0 이라
403
426
  // 자동화가 "currentUser 영구 null" 스캐폴드를 성공으로 처리했다(g controller 의
package/dist/index.js CHANGED
@@ -125,6 +125,7 @@ function renderHelp(version = VERSION) {
125
125
  " gaon g page <Path/Name> Vue 페이지 (Inertia SPA · pageProps 브리지)",
126
126
  " gaon g job <Name> 비동기 잡 (domain/jobs · later/in/at)",
127
127
  " gaon g app <name> 앱 스캐폴드 (apps/<name>/ · routes·controllers·pages·layouts)",
128
+ " gaon g channel <name> 실시간 채널 (정의 + 클라 컴포저블 · --instance = 파라미터화 채널 = 매치·스레드별 동적 방)",
128
129
  " gaon g <type> --overwrite 기존 파일 덮어쓰기 · --app <이름> · --json",
129
130
  " gaon mcp 내장 MCP 서버 · AI 도구 7종 (list_routes·get_schema·run_migration·run_tests·read_agent_doc·run_check·run_doctor · --http)",
130
131
  " gaon hub 실시간 허브 프로세스 (프레즌스 권위·중계 · 리더 선출 HA)",
@@ -151,7 +152,7 @@ function renderHelp(version = VERSION) {
151
152
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
152
153
  */
153
154
  export function parseDoctorChecks(argv) {
154
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 30종)를 단일 출처로 쓴다 — 과거
155
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 31종)를 단일 출처로 쓴다 — 과거
155
156
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
156
157
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
157
158
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -172,7 +173,7 @@ export function parseDoctorChecks(argv) {
172
173
  }
173
174
  // 결정 411: 모르는 이름은 여전히 무시하되(안전 방향 — 전체 검사로 넓어짐) **조용히**
174
175
  // 넘기지 않는다. 오타 하나가 "그 검사만 돌렸다" 는 착각으로 이어지고, 전부 오타면
175
- // 30종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
176
+ // 31종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
176
177
  if (unknown.length > 0) {
177
178
  process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
178
179
  ` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +
@@ -472,7 +473,7 @@ export function runCli(argv, opts = {}) {
472
473
  });
473
474
  return;
474
475
  }
475
- // `gaon doctor` — 정적 검사(M9-E · 30 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
476
+ // `gaon doctor` — 정적 검사(M9-E · 31 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
476
477
  // 선택 실행, --json 은 자동화 파싱용.
477
478
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
478
479
  if (argv[0] === "doctor") {
@@ -629,7 +630,7 @@ export function runCli(argv, opts = {}) {
629
630
  process.exitCode = code;
630
631
  return;
631
632
  }
632
- const known = ["controller", "model", "page", "job", "app"];
633
+ const known = ["controller", "model", "page", "job", "app", "channel"];
633
634
  const type = argv[1];
634
635
  if (type && known.includes(type)) {
635
636
  const rest = argv.slice(2);
@@ -638,6 +639,7 @@ export function runCli(argv, opts = {}) {
638
639
  let app;
639
640
  let overwrite = false;
640
641
  let json = false;
642
+ let instance = false;
641
643
  for (let i = 0; i < rest.length; i++) {
642
644
  const a = rest[i];
643
645
  if (a === "--json") {
@@ -648,6 +650,10 @@ export function runCli(argv, opts = {}) {
648
650
  overwrite = true;
649
651
  continue;
650
652
  }
653
+ if (a === "--instance") {
654
+ instance = true;
655
+ continue;
656
+ }
651
657
  if (a === "--app") {
652
658
  app = rest[++i];
653
659
  continue;
@@ -662,19 +668,21 @@ export function runCli(argv, opts = {}) {
662
668
  ? "Posts/Index"
663
669
  : type === "app"
664
670
  ? "admin"
665
- : "Post";
671
+ : type === "channel"
672
+ ? "chatMessages"
673
+ : "Post";
666
674
  process.stderr.write(` ✗ gaon g ${type}: 이름이 없습니다.\n` +
667
675
  ` → 예: gaon g ${type} ${example}\n`);
668
676
  process.exitCode = 1;
669
677
  return;
670
678
  }
671
- const code = runGenerateCommand(type, name, { app, overwrite, json });
679
+ const code = runGenerateCommand(type, name, { app, overwrite, json, instance });
672
680
  process.exitCode = code;
673
681
  return;
674
682
  }
675
683
  process.stderr.write(` ✗ 알 수 없는 제너레이터: ${argv[1] ?? "(없음)"}\n` +
676
- ` → 현재 지원: gaon g auth | ui-kit | controller | model | page | job | app\n` +
677
- ` → 옵션: --app <이름> · --overwrite · --json\n`);
684
+ ` → 현재 지원: gaon g auth | ui-kit | controller | model | page | job | app | channel\n` +
685
+ ` → 옵션: --app <이름> · --overwrite · --json · --instance (channel 전용)\n`);
678
686
  process.exitCode = 1;
679
687
  return;
680
688
  }
@@ -0,0 +1,7 @@
1
+ import type { ScaffoldFile } from './controller.js';
2
+ /** 채널 정의 파일 — 정적/인스턴스 변형(결정 440). */
3
+ export declare function channelScaffold(rawName: string, app: string, instance: boolean): ScaffoldFile;
4
+ /** 클라 구독 컴포저블 스텁 — useChannel 정본(결정 87 · 인스턴스는 결정 440). */
5
+ export declare function channelComposableScaffold(rawName: string, app: string, instance: boolean): ScaffoldFile;
6
+ /** `gaon g channel` 이 생성하는 파일 목록(정의 + 컴포저블). */
7
+ export declare function channelScaffoldFiles(rawName: string, app: string, instance: boolean): ScaffoldFile[];
@@ -0,0 +1,121 @@
1
+ // @gaonjs/cli · scaffold · channel (결정 442)
2
+ //
3
+ // `gaon g channel <name> [--instance] [--app <앱>]` — 실시간 채널 스캐폴드.
4
+ // 소켓 앱(채팅·게임·알림)의 첫 파일 두 개를 관례 그대로 깐다:
5
+ // apps/<앱>/channels/<camel>.ts — 채널 정의(파일=등록 · §7)
6
+ // apps/<앱>/composables/use<Pascal>.ts — 클라 구독 컴포저블(useChannel 정본 · 결정 87)
7
+ //
8
+ // --instance 는 파라미터화 채널(결정 440) — 매치별 게임 방·게시물별 댓글
9
+ // 스레드처럼 "같은 규칙, 다른 방" 을 채널 파일 하나로. 정의에 instance: true
10
+ // 가 선언되고 컴포저블이 인스턴스 키를 인자로 받는다.
11
+ /** 채널 이름 정규화 — 파일명 = 채널 이름(camelCase 관례 · 루트 §네이밍). */
12
+ function channelNames(rawName) {
13
+ const trimmed = rawName.trim();
14
+ if (!trimmed) {
15
+ throw new Error(`[gaon g channel] 채널 이름이 비어 있습니다. 예: gaon g channel chatMessages`);
16
+ }
17
+ if (trimmed.includes(':')) {
18
+ throw new Error(`[gaon g channel] 채널 이름에 ':' 는 쓸 수 없습니다(이름은 파일명입니다).\n` +
19
+ ` → 동적 방이 필요하면 --instance 를 쓰세요: gaon g channel ${trimmed.split(':')[0]} --instance`);
20
+ }
21
+ const pascal = trimmed
22
+ .split(/[_\-\s]+/)
23
+ .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ''))
24
+ .join('');
25
+ const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
26
+ return { camel, pascal };
27
+ }
28
+ /** 채널 정의 파일 — 정적/인스턴스 변형(결정 440). */
29
+ export function channelScaffold(rawName, app, instance) {
30
+ const { camel } = channelNames(rawName);
31
+ const lines = instance
32
+ ? [
33
+ `// ${camel} 채널 — 파라미터화(인스턴스) 채널(결정 440 · gaon g channel --instance).`,
34
+ `// 파일 존재 = 등록. 이 파일 하나가 무한 실행 인스턴스를 서비스한다 —`,
35
+ `// 접속 경로 /gaon/ws/${camel}/<인스턴스> · 전송·프레즌스·인가가 인스턴스별로 격리된다.`,
36
+ `import { channel } from 'gaonjs/async'`,
37
+ ``,
38
+ `export default channel({`,
39
+ ` instance: true,`,
40
+ ` // 인스턴스 단위 입장 판정 — ctx.instance 가 인스턴스 키(예: 매치 id·게시물 id)다.`,
41
+ ` // 참가자 검증이 필요하면 DB 로 확인한다(예: 매치 참가자 테이블 조회).`,
42
+ ` authorize(ctx) {`,
43
+ ` return ctx.user != null // 로그인 사용자만 — 공개 관전형이면 이 훅을 지운다`,
44
+ ` },`,
45
+ ` // 접속자 목록에 노출할 공개 메타(민감 정보 금지 — 전원에게 공개된다).`,
46
+ ` presenceInfo(ctx) {`,
47
+ ` return { name: (ctx.user as { name?: string } | null)?.name ?? '익명' }`,
48
+ ` },`,
49
+ ` async onMessage(ctx, data) {`,
50
+ ` // 본문만 검증·상한해 취하고 작성자는 서버 권위(ctx.user)로 붙인다 —`,
51
+ ` // 클라 페이로드 통째 에코는 금지(작성자 위조·XSS · agents/realtime.md §2).`,
52
+ ` const raw = (data as { text?: unknown }).text`,
53
+ ` const text = typeof raw === 'string' ? raw.trim().slice(0, 2000) : ''`,
54
+ ` if (!text) return`,
55
+ ` // ctx.broadcast 는 자기 인스턴스(${camel}:<키>)로 자동 스코프된다.`,
56
+ ` ctx.broadcast({ text, member: ctx.member })`,
57
+ ` },`,
58
+ `})`,
59
+ ``,
60
+ ]
61
+ : [
62
+ `// ${camel} 채널 — 실시간 채널(§7 · gaon g channel).`,
63
+ `// 파일 존재 = 등록. 접속 경로 /gaon/ws/${camel} · 클라는 use${channelNames(rawName).pascal}() 로 구독한다.`,
64
+ `import { channel } from 'gaonjs/async'`,
65
+ ``,
66
+ `export default channel({`,
67
+ ` // 연결 인가 — false 면 거부(4401). 공개 채널이면 이 훅을 지운다.`,
68
+ ` authorize(ctx) {`,
69
+ ` return ctx.user != null`,
70
+ ` },`,
71
+ ` // 접속자 목록에 노출할 공개 메타(민감 정보 금지 — 전원에게 공개된다).`,
72
+ ` presenceInfo(ctx) {`,
73
+ ` return { name: (ctx.user as { name?: string } | null)?.name ?? '익명' }`,
74
+ ` },`,
75
+ ` async onMessage(ctx, data) {`,
76
+ ` // 본문만 검증·상한해 취하고 작성자는 서버 권위(ctx.user)로 붙인다 —`,
77
+ ` // 클라 페이로드 통째 에코는 금지(작성자 위조·XSS · agents/realtime.md §2).`,
78
+ ` const raw = (data as { text?: unknown }).text`,
79
+ ` const text = typeof raw === 'string' ? raw.trim().slice(0, 2000) : ''`,
80
+ ` if (!text) return`,
81
+ ` ctx.broadcast({ text, member: ctx.member })`,
82
+ ` },`,
83
+ `})`,
84
+ ``,
85
+ ];
86
+ return { path: `apps/${app}/channels/${camel}.ts`, contents: lines.join('\n') };
87
+ }
88
+ /** 클라 구독 컴포저블 스텁 — useChannel 정본(결정 87 · 인스턴스는 결정 440). */
89
+ export function channelComposableScaffold(rawName, app, instance) {
90
+ const { camel, pascal } = channelNames(rawName);
91
+ const lines = instance
92
+ ? [
93
+ `// use${pascal} — ${camel} 인스턴스 채널 구독 컴포저블(gaon g channel --instance).`,
94
+ `// 페이지/컴포넌트 setup 에서 호출한다(마운트 접속·언마운트 정리 자동).`,
95
+ `import { useChannel } from 'gaonjs/vue'`,
96
+ ``,
97
+ `export function use${pascal}(instance: string | number) {`,
98
+ ` // messages(반응형)·members(접속자 명단)·status·send 를 그대로 노출한다.`,
99
+ ` const { messages, members, status, send } = useChannel('${camel}', { instance })`,
100
+ ` return { messages, members, status, send }`,
101
+ `}`,
102
+ ``,
103
+ ]
104
+ : [
105
+ `// use${pascal} — ${camel} 채널 구독 컴포저블(gaon g channel).`,
106
+ `// 페이지/컴포넌트 setup 에서 호출한다(마운트 접속·언마운트 정리 자동).`,
107
+ `import { useChannel } from 'gaonjs/vue'`,
108
+ ``,
109
+ `export function use${pascal}() {`,
110
+ ` // messages(반응형)·members(접속자 명단)·status·send 를 그대로 노출한다.`,
111
+ ` const { messages, members, status, send } = useChannel('${camel}')`,
112
+ ` return { messages, members, status, send }`,
113
+ `}`,
114
+ ``,
115
+ ];
116
+ return { path: `apps/${app}/composables/use${pascal}.ts`, contents: lines.join('\n') };
117
+ }
118
+ /** `gaon g channel` 이 생성하는 파일 목록(정의 + 컴포저블). */
119
+ export function channelScaffoldFiles(rawName, app, instance) {
120
+ return [channelScaffold(rawName, app, instance), channelComposableScaffold(rawName, app, instance)];
121
+ }
@@ -3,6 +3,7 @@ export { controllerScaffold } from './controller.js';
3
3
  export { modelScaffold, modelScaffoldFiles, schemaScaffold } from './model.js';
4
4
  export { pageScaffold } from './page.js';
5
5
  export { jobScaffold, jobTestScaffold } from './job.js';
6
+ export { channelScaffold, channelComposableScaffold, channelScaffoldFiles } from './channel.js';
6
7
  export { appScaffoldFiles, validateAppName } from './app.js';
7
8
  export { inflectModel, toCamel, toPascal, singularize, pluralize, type ModelNames, } from './inflect.js';
8
9
  import type { ScaffoldFile } from './controller.js';
@@ -9,6 +9,7 @@ export { controllerScaffold } from './controller.js';
9
9
  export { modelScaffold, modelScaffoldFiles, schemaScaffold } from './model.js';
10
10
  export { pageScaffold } from './page.js';
11
11
  export { jobScaffold, jobTestScaffold } from './job.js';
12
+ export { channelScaffold, channelComposableScaffold, channelScaffoldFiles } from './channel.js';
12
13
  export { appScaffoldFiles, validateAppName } from './app.js';
13
14
  export { inflectModel, toCamel, toPascal, singularize, pluralize, } from './inflect.js';
14
15
  /**
@@ -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` 검사 30
116
+ ### 2.2 `gaon doctor` 검사 31
117
117
 
118
118
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
119
119
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -144,7 +144,8 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
144
144
  27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
145
145
  28. `render-return` — 액션이 `this.render`/`this.redirect`/`this.json` 을 호출만 하고 `return` 하지 않음 = 응답이 버려져 조용히 204(백지) — `return this.render(...)` 로 고치라 (결정 340 · 경고)
146
146
  29. `channel-collision` — 두 앱이 **같은 이름의 채널**을 각각 정의 = **에러**. 채널 이름은 전역이다(브로드캐스트 subject `gaon.chan.<이름>`·프레즌스 키에 앱 프리픽스 없음) — 한 앱의 broadcast 가 다른 앱 연결로 팬아웃되고 접속자 목록이 병합되며, 두 정의의 `authorize` 가 갈리면 공개 쪽 규칙으로 메시지가 샌다. 앱마다 이름을 분리하거나(클라이언트 `useChannel` 인자도 함께), 일부러 공유하는 채널이면 정의를 `shared/channels/<이름>.ts` 하나에 두고 각 앱 채널 파일에서 재수출하라(재수출은 통과 · 정의 하나 = 인가 규칙 하나) — 잡·리스너의 동명 등록 throw(결정 271)와 같은 계열의 정적 검사 (`agents/realtime.md` §2)
147
- 30. `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)
147
+ 30. `channel-instance-authorize` — `instance: true` 채널(파라미터화 채널 · 결정 440)에 `authorize` 가 없음 = **경고**. 인스턴스 채널은 임의 문자열 키로 무한 실행 인스턴스(`/gaon/ws/<이름>/<인스턴스>`) 열리므로, authorize 없으면 누구나 아무 인스턴스에나 입장한다. 매치·스레드처럼 참가자가 정해진 채널이면 `authorize(ctx)` 에서 `ctx.instance` 입장을 판정하라 공개 관전형(누구나 입장)이 의도면 무시해도 된다(재수출 정의는 대상 모듈을 따라가 판정 · `agents/realtime.md` §2.7)
148
+ 31. `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)
148
149
 
149
150
  ## 3. 로직 배치 One Way 판단표
150
151
 
@@ -205,7 +206,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
205
206
  ```bash
206
207
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
207
208
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
208
- gaon doctor # 정적 검사 30종 (§2.2)
209
+ gaon doctor # 정적 검사 31종 (§2.2)
209
210
  ```
210
211
 
211
212
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -215,7 +216,7 @@ gaon doctor # 정적 검사 30종 (§2.2)
215
216
  | `gaon new <name>` | 프로젝트 스캐폴드 |
216
217
  | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**serve·work·hub 자동 기동**·**코드 변경 감시·재시작** · 결정 211) |
217
218
  | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
218
- | `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` · `g auth --app <앱> --public` = 비-web 앱에 공개 회원가입(`/registration/new`)을 opt-in(기본: web=공개·비-web=역할 게이트 · 결정 155) |
219
+ | `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app`·`channel` · `g auth --app <앱> --public` = 비-web 앱에 공개 회원가입(`/registration/new`)을 opt-in(기본: web=공개·비-web=역할 게이트 · 결정 155) · `g channel <이름> [--instance]` = 실시간 채널(정의 + 클라 컴포저블 · `--instance` = 파라미터화 채널 = 매치·스레드별 동적 방 · 결정 440·442 · `agents/realtime.md` §2.7) |
219
220
  | `gaon g auth --jwt --app <앱>` | **API(JWT) 앱 변형** — 이 명령 **하나**가 앱 폴더째 만든다(토큰 컨트롤러 3종 + `strategy:'jwt'` app.config + `<APP>_JWT_SECRET` 시드 · 페이지·UI 킷·회원가입 없음). `gaon g app` 을 먼저 돌리지 않는다 — 이미 있는 app.config 는 자동 배선을 못 해 exit 1 이고, 쓰지 않는 Vue 프론트가 남는다. web 앱은 세션이 정본이라 `--jwt` 불가 (결정 337·338) |
220
221
  | `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
221
222
  | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
@@ -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 # 정적 검사 30종 (상세 AGENTS §2.2)
89
+ gaon doctor # 정적 검사 31종 (상세 AGENTS §2.2)
90
90
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
91
91
  ```
92
92
 
@@ -22,6 +22,10 @@
22
22
  등록 (다른 배터리와 같은 관례). 파일명이 채널 이름이 되고, default
23
23
  export 를 런타임이 집는다. 파일명은 camelCase (루트 §네이밍).
24
24
 
25
+ **시작은 스캐폴드가 정본이다(결정 442)**: `gaon g channel <이름>` 이 채널
26
+ 정의 + 클라 구독 컴포저블 두 파일을 관례 그대로 깐다. 동적 방(매치·스레드)은
27
+ `gaon g channel <이름> --instance`(파라미터화 채널 · §2.7).
28
+
25
29
  **채널 이름은 전역 네임스페이스다 — 앱 소속이 아니다.** 파일이 앱 폴더 아래
26
30
  있는 것은 **정의(훅·인가)와 WS 접속 경로**가 앱에 매인다는 뜻이고, **이름으로
27
31
  식별되는 것들은 전부 앱을 가로지른다**:
@@ -30,7 +34,8 @@ export 를 런타임이 집는다. 파일명은 camelCase (루트 §네이밍).
30
34
  |---|---|---|
31
35
  | WS 접속 경로 | **앱** | `<앱 프리픽스>/gaon/ws/<이름>`(web 의 `room`→`/gaon/ws/room` · admin 의 `adminRoom`→`/admin/gaon/ws/adminRoom`) |
32
36
  | `authorize`·`presenceInfo`·훅 | **앱** | 그 앱 폴더의 채널 파일이 실행된다 |
33
- | 발화(`broadcast`·`sendToUsers`) | **전역** | NATS subject `gaon.chan.<이름>` — 이름만 씀 |
37
+ | `broadcast`(채널 전체) | **전역** | NATS subject `gaon.chan.<이름>` — 이름만 씀 |
38
+ | `sendToUsers`(유저 타겟) | **전역** | NATS subject `gaon.user.<member>` — 대상 서버에만 도달(결정 439) |
34
39
  | 프레즌스 로스터 | **전역** | 허브 KV 키 `presence.<이름>.<멤버>` — 이름만 씀 |
35
40
 
36
41
  - **앱간 동명 채널은 충돌한다.** `apps/web/channels/room.ts` 와
@@ -93,12 +98,13 @@ export default channel({
93
98
  | 멤버 | 설명 |
94
99
  | --- | --- |
95
100
  | `ctx.channel` | 채널 이름 |
101
+ | `ctx.instance` | 인스턴스 키(§2.7 · `instance: true` 채널 = `string` · 정적 채널 = `undefined` — 타입으로 갈림) |
96
102
  | `ctx.user` | 세션 인증 사용자(M5) 또는 `null` |
97
103
  | `ctx.member` | 이 연결의 멤버 식별자 |
98
104
  | `ctx.query` | 연결 쿼리 파라미터 (예: `?room=42`) |
99
105
  | `ctx.send(data)` | 이 연결에만 전송 |
100
- | `ctx.broadcast(data)` | 채널 전체(모든 서버의 모든 연결)로 브로드캐스트 |
101
- | `ctx.presence()` | 현재 접속자 목록(허브 권위 · 전 서버 동기화) `Promise<PresenceMember[]>` |
106
+ | `ctx.broadcast(data)` | 채널 전체(모든 서버의 모든 연결)로 브로드캐스트 — 인스턴스 채널은 자기 인스턴스로 자동 스코프(§2.7) |
107
+ | `ctx.presence()` | 현재 접속자 목록(허브 권위 · 전 서버 동기화) `Promise<PresenceMember[]>` — 인스턴스 채널은 자기 인스턴스 로스터 |
102
108
 
103
109
  멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
104
110
 
@@ -145,6 +151,8 @@ export default controller({
145
151
  경로를 그대로 재사용하므로 seal 앱에서도 봉인된다(추가 처리 불필요).
146
152
  - **realtime(NATS) 미설정 앱**에서 호출하면 수리 안내와 함께 throw · **구독자 없음**이면 조용히
147
153
  아무 데도 안 간다(fire-and-forget · 예외 아님).
154
+ - **인스턴스 채널(§2.7)은 `{ instance }` 옵션으로 스코프한다** — `broadcast('match', data,
155
+ { instance: '42' })`. 이름에 `:` 를 실어 흉내내는 것은 fail-loud 로 거부된다(결정 440).
148
156
 
149
157
  ### 2.6 특정/다중 유저 타겟 발송 (결정 227)
150
158
 
@@ -170,14 +178,195 @@ export default controller({
170
178
  ```
171
179
 
172
180
  - **전달 대상** — 대상 유저의 **살아있는 연결에만** 간다. 멀티탭이면 그 유저의 **모든 연결**이
173
- 받고(연결별 전달), 멀티서버여도 대상이 어느 서버에 붙어 있든 받는다(각 서버가 자기 로컬
174
- 연결을 필터). 비대상 유저는 받는다.
181
+ 받고(연결별 전달), 멀티서버여도 대상이 어느 서버에 붙어 있든 받는다. 비대상 유저는 안 받는다.
182
+ 내부적으로 대상 유저의 subject(`gaon.user.<member>`) 직접 발행해 **대상을 호스팅하는 서버
183
+ 에만** 도달한다(결정 439 · NATS 관심 라우팅) — 큰 방에 소수만 지정해도 무관한 서버는 수신
184
+ 자체가 없다. 채널 스코프는 유지된다(대상이 그 채널에 없으면 안 받음).
175
185
  - **반환 = 도달한 대상 유저 수**(`Promise<number>`). 그 채널에 접속(present)한 대상 수를
176
186
  프레즌스 권위(허브 KV)에서 센다. **대상이 전원 오프라인이면 `0` 을 정상 반환**한다 — throw
177
187
  가 아니라 반환값으로 미도달을 알린다(조용히 삼키지 않음). 멀티탭 유저는 연결이 여럿이어도
178
188
  present **유저** 기준이라 `1` 로 센다.
179
189
  - **아키텍처(errata E-2)** — `broadcast` 와 같은 NATS 채널 subject 를 타므로 새 연결·프로토콜이
180
190
  없다. seal 재봉인·authorize 규칙(§2.5)도 그대로다(authorize 는 구독 시점 게이트 · 재실행 없음).
191
+ - **인스턴스 채널(§2.7)은 `{ instance }` 옵션으로 스코프한다** — `sendToUsers('match', id, data,
192
+ { instance: '42' })`. 주소는 유저·스코프는 인스턴스(결정 440 · 결정 439 와 직교) — 같은 유저가
193
+ 다른 인스턴스에 열어 둔 연결은 받지 않는다.
194
+
195
+ ### 2.7 파라미터화(인스턴스) 채널 (결정 440)
196
+
197
+ 채널 파일 하나가 **무한 실행 인스턴스**를 서비스한다 — 매치별 게임 방, 게시물별
198
+ 댓글 스레드처럼 "같은 규칙, 다른 방" 이 필요할 때 쓴다. 정의에 `instance: true`
199
+ 를 선언하면 채널 **정체성(identity)이 `<이름>:<인스턴스>`** 가 되고(Phoenix topic
200
+ 모델), **전송 subject · 프레즌스 로스터 · 인가 · broadcast 네 축이 전부 인스턴스
201
+ 단위로 격리**된다. 정의는 여전히 파일=정적 등록이다(임의 문자열 채널 금지 · 없는
202
+ 이름은 4404) — 인스턴스는 그 정의의 실행 키일 뿐이다.
203
+
204
+ ```ts
205
+ // apps/web/channels/match.ts — 게임 매치: 매치 id 가 인스턴스 키
206
+ import { channel } from 'gaonjs/async'
207
+ import { MatchPlayer } from '../../../domain/models/MatchPlayer.js'
208
+
209
+ export default channel({
210
+ instance: true, // ← 파라미터화 선언
211
+ async authorize(ctx) {
212
+ // ctx.instance = 매치 id — 인스턴스 단위 입장 판정(참가자만)
213
+ const u = ctx.user as { id: bigint } | null
214
+ if (!u) return false
215
+ return await MatchPlayer.where({ matchId: BigInt(ctx.instance), userId: u.id }).exists()
216
+ },
217
+ onMessage(ctx, data) {
218
+ // ctx.broadcast 는 자기 인스턴스(match:<id>)로 자동 스코프 — 다른 매치는 못 받는다
219
+ ctx.broadcast({ move: data })
220
+ },
221
+ })
222
+ ```
223
+
224
+ ```ts
225
+ // apps/web/channels/thread.ts — 채팅 스레드: 게시물 id 가 인스턴스 키(공개 관전형)
226
+ import { channel } from 'gaonjs/async'
227
+
228
+ export default channel({
229
+ instance: true,
230
+ // authorize 생략 = 누구나 아무 스레드 구독 가능(공개 댓글). doctor 가
231
+ // channel-instance-authorize 경고를 내는데, 공개가 의도면 무시해도 된다.
232
+ presenceInfo(ctx) {
233
+ return { name: (ctx.user as { name: string } | null)?.name ?? '익명' }
234
+ },
235
+ })
236
+ ```
237
+
238
+ ```ts
239
+ // 클라이언트 — 인스턴스 키를 지정해 구독한다(경로: /gaon/ws/match/42)
240
+ const game = useChannel('match', { instance: matchId }) // 숫자는 자동 문자열화
241
+ const comments = useChannel('thread', { instance: postId })
242
+ ```
243
+
244
+ ```ts
245
+ // 서버 발화·조회 — { instance } 옵션으로 특정 인스턴스에만 스코프한다
246
+ broadcast('match', { round: 2 }, { instance: '42' }) // match:42 전원
247
+ await sendToUsers('match', userId, { note: '…' }, { instance: '42' }) // match:42 의 그 유저만
248
+ const players = await presenceList('match', { instance: '42' }) // match:42 로스터
249
+ const rooms = await presenceOf(`user:${userId}`) // 역방향: 이 유저가 지금 있는 곳
250
+ // rooms = [{ channel: 'match', instance: '42' }, { channel: 'lobby' }, …]
251
+ const active = await instancesOf('match') // 채널→활성 인스턴스: 지금 열린 방
252
+ // active = ['42', '77', …] — 점유(멤버 ≥ 1)된 인스턴스 키만 · 결정 442
253
+ ```
254
+
255
+ 페이지까지의 완결 조각(매치 예 — 스레드도 이름·키만 다르고 동일하다):
256
+
257
+ ```vue
258
+ <!-- apps/web/pages/Match/Show.vue — 컨트롤러가 render('Match/Show', { matchId: String(id) }) -->
259
+ <script setup lang="ts">
260
+ import { ref } from 'vue'
261
+ import { pageProps } from 'gaonjs/vue'
262
+ import { useMatch } from '../../composables/useMatch.js' // gaon g channel match --instance 가 생성
263
+
264
+ const props = pageProps<'web:matches#show'>()
265
+ // 인스턴스 키(매치 id)로 그 방 하나만 구독 — 마운트 접속·언마운트 정리 자동.
266
+ const { messages, members, status, send } = useMatch(props.matchId)
267
+ const draft = ref('')
268
+ </script>
269
+
270
+ <template>
271
+ <section>
272
+ <ul v-if="status === 'open'"><li v-for="m in members" :key="m.id">{{ m.info?.name }}</li></ul>
273
+ <ul><li v-for="(msg, i) in messages" :key="i">{{ msg }}</li></ul>
274
+ <input v-model="draft" @keyup.enter="send({ text: draft }) && (draft = '')" />
275
+ </section>
276
+ </template>
277
+ ```
278
+
279
+ - **정합은 fail-loud(4400)다.** `instance: true` 채널에 인스턴스 누락, 정적
280
+ 채널에 인스턴스 지정, 키 128자 초과 — 전부 연결이 `4400` 으로 거부된다
281
+ (수리 안내가 에러 프레임에 실린다). 모호한 "기본 인스턴스" 는 없다(The One
282
+ Way). 클라이언트 `useChannel` 은 `4400` 을 설정 오류로 보고 **재연결하지
283
+ 않는다**(§4 종단 표).
284
+ - **인스턴스 키는 임의 문자열이다**(128자 이하) — 매치 id·게시물 id 같은
285
+ 식별자를 그대로 쓴다. subject/KV 에는 identity 전체가 base64url 로 인코딩돼
286
+ 들어가므로 특수문자 주입 걱정이 없다.
287
+ - **`ctx.instance` 는 타입으로 갈린다** — `instance: true` 채널의 훅에서는
288
+ `string`(항상 존재), 정적 채널에서는 `undefined`(string 으로 쓰면 컴파일 에러).
289
+ - **`presenceOf(member)` 는 역방향 조회다** — 멤버(`user:<id>`·`conn:<uuid>`)가
290
+ 지금 속한 채널(이름+인스턴스) 목록을 역방향 인덱스 한 번의 키 스캔으로 얻는다
291
+ (전체 스캔 없음 · 오프라인이면 빈 배열). "이 유저가 어느 매치에 있나" 를 서버
292
+ 어디서든 답할 수 있다.
293
+ - **`presenceList(name, { instance })` 는 서버 개시 로스터 조회다** —
294
+ `ctx.presence()`(연결 훅 안 전용)의 서버 개시 대칭. broadcast 와 같은 배선
295
+ (`gaon serve`/`gaon work` 자동)을 쓴다.
296
+ - **정적 채널은 인스턴스 없는 특수경우다** — identity=이름 그대로라 기존 채널의
297
+ 와이어·KV·API 는 아무것도 변하지 않는다(마이그레이션 0).
298
+ - **앱간 동명 규칙(§2)은 인스턴스 채널에도 동일하다** — identity 는 이름에서
299
+ 파생되므로 이름이 전역이면 인스턴스도 전역이다.
300
+
301
+ ### 2.8 인스턴스 생명주기 이벤트 + 로비 패턴 (결정 442)
302
+
303
+ 인스턴스에 **첫 멤버가 들어오면 `InstanceOpened`, 마지막 멤버가 떠나면
304
+ `InstanceClosed`** 가 발행된다(전 서버 합산 · 허브가 전역 로스터 단일 권위로
305
+ 판정). 허브는 도메인 DB 를 모르므로 방 row 생성/삭제 같은 도메인 반영은
306
+ **리스너**가 한다 — 기존 이벤트 배터리 표면 그대로다:
307
+
308
+ ```ts
309
+ // domain/listeners/onMatchOpened.ts — 파일 존재 = 등록(리스너 배터리 공통)
310
+ import { on, InstanceOpened, broadcast } from 'gaonjs/async'
311
+ import { Room } from '../models/Room.js'
312
+
313
+ export default on(InstanceOpened, async ({ channel, instance }) => {
314
+ if (channel !== 'match') return // 관심 채널만(이벤트는 채널 공통)
315
+ await Room.create({ key: instance }) // DB 반영 — 워커에서 실행된다
316
+ broadcast('lobby', { type: 'room-opened', key: instance }) // 로비 라이브 갱신
317
+ })
318
+ ```
319
+
320
+ ```ts
321
+ // domain/listeners/onMatchClosed.ts
322
+ import { on, InstanceClosed, broadcast } from 'gaonjs/async'
323
+ import { Room } from '../models/Room.js'
324
+
325
+ export default on(InstanceClosed, async ({ channel, instance }) => {
326
+ if (channel !== 'match') return
327
+ await Room.where('key', '=', instance).delete()
328
+ broadcast('lobby', { type: 'room-closed', key: instance })
329
+ })
330
+ ```
331
+
332
+ - **핸들러는 워커 딱 하나에서만 돈다** — 이벤트 스트림(JetStream)의 리스너
333
+ durable 컨슈머를 전 `gaon work` 가 공유하므로, 여러 워커를 띄워도 이벤트당
334
+ 한 워커만 처리한다(개발자가 서버를 고르지 않는다). at-least-once 라
335
+ **핸들러는 멱등하게**(이벤트 배터리 공통 관례 — 위 예시의 create 는 key
336
+ unique + upsert 또는 존재 검사로 감싸는 것이 안전하다).
337
+ - **일시적 방 vs 영속 방** — 일시적 방(익명 대화방 등)은 이 두 이벤트가 곧
338
+ 생성/삭제다. 영속 방(게임 매치·게시물 스레드)은 방 row 를 서비스로 먼저
339
+ 만들고(DB = 진실 원천 · 참가 authorize 도 그 row 로) 이 이벤트는 **점유
340
+ 상태**(열림/비었음) 갱신에 쓴다.
341
+ - **재발행 규율** — 같은 인스턴스에 둘째 멤버가 들어와도 opened 는 다시 나지
342
+ 않고, 완전히 비었다 다시 점유되면 opened 가 다시 난다. 허브 재시작·failover
343
+ 는 이벤트를 중복 발행하지 않는다(활성 인스턴스 인덱스가 dedup 앵커 ·
344
+ 크래시 창의 어긋남만 대사 시점에 보정 발행).
345
+
346
+ **로비 = 일반 채널이다(프리미티브 신설 없음).** 활성 방 목록 화면의 정본 패턴:
347
+
348
+ ```ts
349
+ // apps/web/controllers/lobby.ts — 최초 렌더: 활성 방 목록을 조회해 props 로
350
+ import { controller } from 'gaonjs/web'
351
+ import { instancesOf } from 'gaonjs/async'
352
+
353
+ export default controller({
354
+ async show() {
355
+ // 영속 방이면 DB(Room.all())가, 일시적 방이면 instancesOf 가 첫 목록이다.
356
+ return this.render('Lobby', { rooms: await instancesOf('match') })
357
+ },
358
+ })
359
+ ```
360
+
361
+ ```ts
362
+ // apps/web/channels/lobby.ts — 평범한 정적 채널(공개 관전)
363
+ import { channel } from 'gaonjs/async'
364
+ export default channel({})
365
+ ```
366
+
367
+ 클라는 최초 목록(props) 위에 `useChannel('lobby')` 구독으로 `room-opened`/
368
+ `room-closed` 를 반영한다 — 리스너의 `broadcast('lobby', …)` 단일 발행이 전
369
+ 서버 로비 구독자에게 팬아웃된다(§2.5).
181
370
 
182
371
  ### 3. 프레즌스
183
372
 
@@ -248,6 +437,7 @@ export function useRoom(roomId: number) {
248
437
 
249
438
  | 옵션 | 기본 | 뜻 |
250
439
  |---|---|---|
440
+ | `instance` | — | 파라미터화 채널의 인스턴스 키(§2.7 · 결정 440). 경로 세그먼트(`/gaon/ws/<name>/<instance>`)로 실림 · 숫자는 문자열화 · 서버 정의가 `instance: true` 일 때 필수(누락 = 4400 종단) |
251
441
  | `params` | — | 쿼리 파라미터. **함수형이면 접속·재접속마다 재평가**(회전 토큰 · 결정 344) |
252
442
  | `immediate` | `true` | 마운트 시 자동 접속. `false` 면 접속하지 않고 **`connect()` 를 직접 부른다** |
253
443
  | `maxMessages` | 무제한 | `messages` 보관 상한(초과분은 오래된 것부터 버림 · 결정 303) |
@@ -296,6 +486,7 @@ export function useRoom(roomId: number) {
296
486
 
297
487
  | code | 언제 | 뒤처리 |
298
488
  |---|---|---|
489
+ | `4400` | **인스턴스 계약 위반**(§2.7 · 결정 440 — 인스턴스 채널에 키 누락 · 정적 채널에 키 지정 · 128자 초과) | 재연결 안 함 — 설정 오류. 서버 에러 프레임에 수리 안내가 실린다 |
299
490
  | `4401` | 채널 `authorize` 거부(비로그인·세션 만료·만료 토큰의 익명 강등 포함) | 재연결 안 함 — 로그인으로 유도 |
300
491
  | `4500` | **seal 개봉 실패**(봉인 계약 위반 · 결정 222·318) | 재연결 안 함 — transient 가 아니라 주입/변조/키 불일치. 콘솔에 원인이 찍힌다(`agents/seal.md`) |
301
492
 
@@ -444,6 +635,19 @@ export default channel({
444
635
  접속은 되지만(결정 303 — 과거엔 라이프사이클 훅이 발화하지 않아 **영원히 closed**
445
636
  인 무신호 미접속이었다) 언마운트 정리가 없으므로 **호출자가 `close()` 를 책임**진다.
446
637
  기본 배치는 컴포저블 → 페이지/컴포넌트 setup 에서 호출.
638
+ - **쿼리 파라미터로 "방" 을 흉내내지 말 것** — `useChannel('room', { params: { room: 42 } })`
639
+ + 핸들러 필터는 **전송·프레즌스가 격리되지 않는다**(모든 방이 한 subject·한 로스터 — 다른 방
640
+ 메시지가 전 구독자에게 도달한 뒤 버려지고, 접속자 목록이 섞인다). 방·매치·스레드처럼 실행
641
+ 인스턴스가 갈리는 채널은 **`instance: true` + `useChannel(name, { instance })`**(§2.7 · 결정
642
+ 440)가 정본이다 — subject·로스터·인가가 인스턴스 단위로 격리된다. `params` 는 인증 토큰·표시
643
+ 옵션 같은 **비격리 부가 정보** 전용.
644
+ - **생명주기 리스너는 채널 필터 + 멱등이 기본이다(§2.8)** — `InstanceOpened`/
645
+ `InstanceClosed` 는 전 인스턴스 채널 공통 이벤트라 핸들러 첫 줄에서
646
+ `if (channel !== '내채널') return` 으로 거른다. 전달은 at-least-once 이므로
647
+ DB 반영은 멱등하게(unique 키·존재 검사).
648
+ - **방 생성/삭제를 허브·채널 훅에서 직접 DB 로 밀지 말 것** — 훅(onJoin/onLeave)은
649
+ 연결 단위라 첫/마지막 판정이 서버 로컬에 갇히고(멀티서버에서 틀림), 허브는
650
+ 도메인을 모른다. 전역 첫/마지막은 생명주기 이벤트(§2.8)가 정답 경로다.
447
651
  - **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
448
652
  (`agents/testing.md`).
449
653
 
@@ -473,6 +677,9 @@ export default channel({
473
677
  | 결정 395 | 리스 사임 CAS 삭제 — `stop()` 이 자기 revision 에서만 리더 키 삭제(`previousSeq`) · stale 리더 종료가 활성 리더 키를 지우던 재선출 순단 봉합(endpoint 결정 259 동형 · 허브·스케줄러 공통) |
474
678
  | 결정 399 | 프레즌스 클라 연결 실패 warn(§5) — 단명 연결·리더 미발견 연속 시 스트릭당 1회 log.warn(토큰 불일치·허브 부재 안내) · 건강한 연결에 리셋 · 종전 무로그 재접속 루프 봉합 |
475
679
  | 결정 400 | P3 청소(실시간 축) — 허브 KV 복원이 오염 키에 throw 해 전 인스턴스 crash-loop 하던 것을 try/continue 방어(presenceStats 와 대칭) · 라인 디코더 완결 초과 라인도 onOverflow(fail-closed 통일) |
680
+ | 결정 439 | `sendToUsers` 주소지정 = per-user subject(§2.6) — 대상 member 의 `gaon.user.<member>` 로 직접 발행(NATS 관심 라우팅) · 비대상 서버 수신 0 · 채널 스코프·인가 계약 불변 |
681
+ | 결정 440 | 파라미터화(인스턴스) 채널(§2.7) — `instance: true` 선언 · identity = `<이름>:<인스턴스>`(Phoenix topic 모델) · 전송 subject·프레즌스·인가·broadcast 네 축 identity 격리 · 역방향 인덱스(`member.<멤버>.<채널>`)로 `presenceOf(member)` 한 번 스캔 · `presenceList(name, {instance})` 서버 개시 로스터 · 선언·연결 부정합 = `4400` fail-loud(재연결 없음) · doctor `channel-instance-authorize` 경고 · 정적 채널은 identity=이름 그대로(와이어·KV 무변경) |
682
+ | 결정 442 | 소켓 앱 DX 표면 완성(§2.8) — 인스턴스 생명주기 이벤트 `InstanceOpened`/`InstanceClosed`(허브 발행 · 활성 인스턴스 인덱스 `chan.<이름>.<인스턴스>` 가 dedup 앵커 · 리스너 durable 공유로 워커 하나만 처리) · `instancesOf(name)` 채널→활성 인스턴스 목록(로비 첫 렌더) · 로비 = 일반 채널 + `broadcast` 패턴(전용 API 신설 없음) · `gaon g channel <이름> [--instance]` 스캐폴드(정의 + 컴포저블) |
476
683
 
477
684
  ## `@gaonjs/seal` 켠 앱의 채널
478
685
 
@@ -494,6 +494,11 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
494
494
  `'none'` 은 스펙상 Secure 필수라 `secure: true` 없이 쓰면 부팅 에러다
495
495
  (브라우저의 조용한 쿠키 거부를 fail-loud 로 전환).
496
496
  - 스캐폴드는 `gaon g auth` — 로그인/회원가입 컨트롤러·페이지·라우트 일습.
497
+ - **비-web 앱 인증의 순서는 `gaon g app <앱>` → `gaon g auth --app <앱>` 이다(결정 441).**
498
+ 세션(프론트) 인증은 대상 앱의 프론트 진입(index.html·main.ts·style.css)이 전제다 —
499
+ 없는 앱에 `g auth --app` 을 먼저 돌리면 로그인 페이지가 마운트될 수 없는 반쪽 앱이
500
+ 생기므로, 명령이 생성 전에 fail-loud(exit 1) 로 막고 이 순서를 안내한다. **API(JWT)
501
+ 앱은 반대**로 `gaon g auth --jwt --app <앱>` 단독이 정본(§6 · 결정 338 — `g app` 선행 금지).
497
502
  - **로그인 필요 액션의 정답 = `this.requireAuth()`** (결정 57). notFound 처럼
498
503
  예외로 마감하지만, **`if` 가드 자체가 없다**는 게 핵심:
499
504
 
@@ -793,6 +798,7 @@ export default controller({
793
798
  | 결정 390 | Inertia 렌더의 `Vary: X-Inertia` 는 기존 Vary(CORS `Origin` 등)에 **병합**(치환 아님) |
794
799
  | 결정 391 | JWT 보강 — `refresh` 가 `loadUser(sub)` 실존 확인(부재 계정 재발급 거부) · Bearer 스킴 대소문자 무관(§6) |
795
800
  | 결정 392 | render/JSON props 의 `Map`/`Set` = 수리 안내 에러(조용한 `{}` 봉합 · 함정 §알려진 함정) |
801
+ | 결정 441 | `gaon g auth --app <앱>` 프론트 진입 가드 — 대상 앱에 index.html·main.ts·style.css 가 없으면 생성 전 fail-loud(exit 1) + `g app <앱>` 선행 안내(§5 · 반쪽 앱 생성 차단 · JWT 변형은 해당 없음) |
796
802
  | 결정 393 | 멀티파트 CSRF 403 안내문 = 결정 342 실태(자동 부착 · 커스텀 전송은 `readCsrfToken()`) 로 갱신 |
797
803
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
798
804
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.57.3",
3
+ "version": "0.58.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,13 +32,13 @@
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "typescript": "^5.9.0",
34
34
  "vite": "^7.0.0",
35
- "@gaonjs/async": "0.18.2",
36
- "@gaonjs/data": "0.25.3",
35
+ "@gaonjs/async": "0.20.0",
36
+ "@gaonjs/config": "0.25.4",
37
37
  "@gaonjs/core": "0.3.0",
38
- "@gaonjs/config": "0.25.2",
38
+ "@gaonjs/data": "0.25.3",
39
39
  "@gaonjs/i18n": "0.3.1",
40
40
  "@gaonjs/mail": "0.5.1",
41
- "@gaonjs/web": "0.30.1"
41
+ "@gaonjs/web": "0.31.0"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""