@gaonjs/cli 0.41.5 → 0.41.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cluster.d.ts +38 -0
- package/dist/cluster.js +68 -0
- package/dist/serve.js +30 -3
- package/dist/templates/auth/registration.controller.ts.tpl +6 -1
- package/dist/templates/auth/session.controller.ts.tpl +2 -2
- package/dist/templates/auth/session.secure.controller.ts.tpl +2 -2
- package/dist/templates/auth/user.schema.ts.tpl +1 -1
- package/dist/templates/project/agents/async.md.tpl +5 -0
- package/dist/templates/project/agents/data.md.tpl +4 -1
- package/dist/templates/project/agents/realtime.md.tpl +18 -0
- package/dist/templates/project/agents/security.md.tpl +9 -0
- package/dist/templates/project/agents/web.md.tpl +12 -4
- package/dist/templates/project/compose.prod.yaml.tpl +4 -0
- package/package.json +8 -8
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ReforkPolicy {
|
|
2
|
+
/** 이 시간 이상 산 워커의 죽음은 '정상 교체'로 보고 즉시 refork·streak 리셋. */
|
|
3
|
+
readonly minHealthyMs: number;
|
|
4
|
+
/** 첫 백오프(ms). 이후 연속 부팅 크래시마다 2배. */
|
|
5
|
+
readonly baseMs: number;
|
|
6
|
+
/** 백오프 상한(ms). */
|
|
7
|
+
readonly maxMs: number;
|
|
8
|
+
}
|
|
9
|
+
/** 연속 부팅 크래시 streak 에 대한 백오프 지연(ms). streak≤0 이면 0(즉시). */
|
|
10
|
+
export declare function reforkDelayMs(streak: number, p: Pick<ReforkPolicy, 'baseMs' | 'maxMs'>): number;
|
|
11
|
+
export interface ReforkDeps {
|
|
12
|
+
/** 새 워커를 fork 한다. */
|
|
13
|
+
fork(): void;
|
|
14
|
+
/** delayMs 후 fn 을 실행할 타이머를 건다. 취소 함수를 반환한다. */
|
|
15
|
+
schedule(fn: () => void, delayMs: number): () => void;
|
|
16
|
+
/** 현재 시각(ms). */
|
|
17
|
+
now(): number;
|
|
18
|
+
/** 백오프 결정 통지(로깅용). delayMs=0 은 즉시 refork. */
|
|
19
|
+
onBackoff?(info: {
|
|
20
|
+
delayMs: number;
|
|
21
|
+
streak: number;
|
|
22
|
+
}): void;
|
|
23
|
+
}
|
|
24
|
+
export interface ReforkSupervisor {
|
|
25
|
+
/** 워커가 예기치 않게 죽었을 때 호출. forkedAt = 그 워커를 fork 한 시각. */
|
|
26
|
+
onCrash(forkedAt: number): void;
|
|
27
|
+
/** 대기 중인 백오프 refork 타이머를 모두 취소한다(종료 시). */
|
|
28
|
+
cancelAll(): void;
|
|
29
|
+
/** 현재 연속 부팅 크래시 streak(테스트·진단). */
|
|
30
|
+
readonly streak: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 재fork 감독자를 만든다. cluster 를 직접 만지지 않아 결정적으로 테스트할 수 있다
|
|
34
|
+
* (fork·schedule·now 를 주입). runClusterPrimary 가 실제 cluster 이벤트를 여기 연결한다.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createReforkSupervisor(policy: ReforkPolicy, deps: ReforkDeps): ReforkSupervisor;
|
|
37
|
+
/** 환경변수에서 재fork 정책을 읽는다(비수치·비양수는 기본값으로 폴백). */
|
|
38
|
+
export declare function reforkPolicyFromEnv(env?: NodeJS.ProcessEnv): ReforkPolicy;
|
package/dist/cluster.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @gaonjs/cli · 클러스터 워커 재fork 백오프 (A5 · FINAL-AUDIT-2026-08-04 · 결정 261)
|
|
2
|
+
//
|
|
3
|
+
// `cluster.on('exit')` 가 무조건 즉시 refork 하면, 워커가 부팅 자체를 못 할 때
|
|
4
|
+
// (무효 설정 → 결정 252 로 워커가 부팅 직후 exit1) 무한 고속 crash-loop 폭주가
|
|
5
|
+
// 난다(실측: --workers 2 · 무효 PORT 로 5초에 refork 16회 · 평균 간격 236ms).
|
|
6
|
+
// 정책: 워커가 **짧게 살고 죽으면**(부팅 크래시) 지수 백오프로 refork 를 늦추고,
|
|
7
|
+
// **충분히 오래 산 뒤 죽으면**(정상 교체) 즉시 refork 한다 — 정상 워커 교체 지연 0.
|
|
8
|
+
// 백오프는 상한(maxMs)에서 멈춘다. 253/252 의 단일 프로세스 확정 종료가 클러스터
|
|
9
|
+
// 감독까지 미치지 못하던 공백을 메운다.
|
|
10
|
+
/** 연속 부팅 크래시 streak 에 대한 백오프 지연(ms). streak≤0 이면 0(즉시). */
|
|
11
|
+
export function reforkDelayMs(streak, p) {
|
|
12
|
+
if (streak <= 0)
|
|
13
|
+
return 0;
|
|
14
|
+
return Math.min(p.baseMs * 2 ** (streak - 1), p.maxMs);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 재fork 감독자를 만든다. cluster 를 직접 만지지 않아 결정적으로 테스트할 수 있다
|
|
18
|
+
* (fork·schedule·now 를 주입). runClusterPrimary 가 실제 cluster 이벤트를 여기 연결한다.
|
|
19
|
+
*/
|
|
20
|
+
export function createReforkSupervisor(policy, deps) {
|
|
21
|
+
let streak = 0;
|
|
22
|
+
const cancels = new Set();
|
|
23
|
+
return {
|
|
24
|
+
onCrash(forkedAt) {
|
|
25
|
+
const uptime = deps.now() - forkedAt;
|
|
26
|
+
let delayMs;
|
|
27
|
+
if (uptime >= policy.minHealthyMs) {
|
|
28
|
+
streak = 0; // 정상 교체 — 페널티 없음
|
|
29
|
+
delayMs = 0;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
streak += 1;
|
|
33
|
+
delayMs = reforkDelayMs(streak, policy);
|
|
34
|
+
}
|
|
35
|
+
deps.onBackoff?.({ delayMs, streak });
|
|
36
|
+
if (delayMs <= 0) {
|
|
37
|
+
deps.fork();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let cancel = () => { };
|
|
41
|
+
cancel = deps.schedule(() => {
|
|
42
|
+
cancels.delete(cancel);
|
|
43
|
+
deps.fork();
|
|
44
|
+
}, delayMs);
|
|
45
|
+
cancels.add(cancel);
|
|
46
|
+
},
|
|
47
|
+
cancelAll() {
|
|
48
|
+
for (const c of cancels)
|
|
49
|
+
c();
|
|
50
|
+
cancels.clear();
|
|
51
|
+
},
|
|
52
|
+
get streak() {
|
|
53
|
+
return streak;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** 환경변수에서 재fork 정책을 읽는다(비수치·비양수는 기본값으로 폴백). */
|
|
58
|
+
export function reforkPolicyFromEnv(env = process.env) {
|
|
59
|
+
const num = (v, d) => {
|
|
60
|
+
const n = Number(v);
|
|
61
|
+
return Number.isFinite(n) && n > 0 ? n : d;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
minHealthyMs: num(env.GAON_WORKER_MIN_HEALTHY_MS, 10_000),
|
|
65
|
+
baseMs: num(env.GAON_WORKER_REFORK_BASE_MS, 1_000),
|
|
66
|
+
maxMs: num(env.GAON_WORKER_REFORK_MAX_MS, 30_000),
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -20,6 +20,7 @@ import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
|
|
|
20
20
|
import { registerTsResolve } from './tsResolve.js';
|
|
21
21
|
import { parsePort } from './port.js';
|
|
22
22
|
import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
|
|
23
|
+
import { createReforkSupervisor, reforkPolicyFromEnv } from './cluster.js';
|
|
23
24
|
/**
|
|
24
25
|
* 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
|
|
25
26
|
* (availableParallelism). 0·음수·비수치는 1 로 떨어진다(안전 기본). 결정 84.
|
|
@@ -48,6 +49,8 @@ function humanEvent(e) {
|
|
|
48
49
|
return ` gaon serve · 클러스터 — 워커 ${e.workers}개 fork (node:cluster)`;
|
|
49
50
|
case 'worker-exit':
|
|
50
51
|
return ` ⚠ 워커 종료(pid ${e.pid ?? '?'} · code ${e.code}${e.signal ? ` · ${e.signal}` : ''})${e.restarted ? ' — 교체 fork' : ''}`;
|
|
52
|
+
case 'worker-backoff':
|
|
53
|
+
return ` ⏳ 워커 부팅 크래시 반복(streak ${e.streak}) — ${Math.round(e.delayMs / 100) / 10}s 후 교체 fork (crash-loop 백오프)`;
|
|
51
54
|
case 'stopping':
|
|
52
55
|
return ' gaon serve · 종료 중 (graceful) ...';
|
|
53
56
|
case 'stopped':
|
|
@@ -71,14 +74,36 @@ async function runClusterPrimary(workerCount, opts) {
|
|
|
71
74
|
};
|
|
72
75
|
emit({ kind: 'cluster', workers: workerCount });
|
|
73
76
|
let shuttingDown = false;
|
|
77
|
+
// A5(결정 261): 재fork 백오프. 워커별 fork 시각을 추적해, 부팅 직후 죽는(짧은 수명) 크래시는
|
|
78
|
+
// 지수 백오프로 늦추고 정상 워커 교체는 즉시 처리한다. 감독 로직은 createReforkSupervisor 로
|
|
79
|
+
// 분리(cluster 미의존 · 결정적 테스트 가능). fork 는 이 감독자만 수행한다.
|
|
80
|
+
const forkedAt = new Map();
|
|
81
|
+
const doFork = () => {
|
|
82
|
+
const w = cluster.fork();
|
|
83
|
+
forkedAt.set(w.id, Date.now());
|
|
84
|
+
};
|
|
85
|
+
const supervisor = createReforkSupervisor(reforkPolicyFromEnv(), {
|
|
86
|
+
fork: doFork,
|
|
87
|
+
schedule: (fn, delayMs) => {
|
|
88
|
+
const timer = setTimeout(fn, delayMs);
|
|
89
|
+
return () => clearTimeout(timer);
|
|
90
|
+
},
|
|
91
|
+
now: () => Date.now(),
|
|
92
|
+
onBackoff: ({ delayMs, streak }) => {
|
|
93
|
+
if (delayMs > 0)
|
|
94
|
+
emit({ kind: 'worker-backoff', delayMs, streak });
|
|
95
|
+
},
|
|
96
|
+
});
|
|
74
97
|
for (let i = 0; i < workerCount; i++)
|
|
75
|
-
|
|
98
|
+
doFork();
|
|
76
99
|
cluster.on('exit', (worker, code, signal) => {
|
|
77
100
|
if (shuttingDown)
|
|
78
101
|
return;
|
|
79
|
-
|
|
102
|
+
const at = forkedAt.get(worker.id) ?? Date.now();
|
|
103
|
+
forkedAt.delete(worker.id);
|
|
104
|
+
// 예기치 않은 종료 → 교체 fork(백오프 정책에 따라 즉시 또는 지연)로 워커 수를 유지한다.
|
|
80
105
|
emit({ kind: 'worker-exit', pid: worker.process.pid, code, signal, restarted: true });
|
|
81
|
-
|
|
106
|
+
supervisor.onCrash(at);
|
|
82
107
|
});
|
|
83
108
|
await new Promise((resolvePromise) => {
|
|
84
109
|
const stop = () => {
|
|
@@ -88,6 +113,8 @@ async function runClusterPrimary(workerCount, opts) {
|
|
|
88
113
|
signals.off('SIGINT', stop);
|
|
89
114
|
signals.off('SIGTERM', stop);
|
|
90
115
|
emit({ kind: 'stopping' });
|
|
116
|
+
// 대기 중인 백오프 refork 타이머를 취소한다 — 종료 중 새 워커가 뜨지 않게(결정 261).
|
|
117
|
+
supervisor.cancelAll();
|
|
91
118
|
for (const w of Object.values(cluster.workers ?? {}))
|
|
92
119
|
w?.kill('SIGTERM');
|
|
93
120
|
const killTimer = setTimeout(() => {
|
|
@@ -12,9 +12,14 @@ export default controller({
|
|
|
12
12
|
const { name, email, password } = this.params({
|
|
13
13
|
_row: {} as { name: string; email: string; password: string },
|
|
14
14
|
})
|
|
15
|
+
// 이메일 중복은 스키마 .unique() 가 강제하지만(무결성 backstop), 사용자에겐
|
|
16
|
+
// 친절한 에러를 준다 — 조용한 중복 성공 대신 Signup 을 다시 렌더한다.
|
|
17
|
+
if (await User.where('email', '=', email).first()) {
|
|
18
|
+
return this.render('Auth/Signup', { error: '이미 사용 중인 이메일입니다.' })
|
|
19
|
+
}
|
|
15
20
|
const passwordDigest = await hashPassword(password)
|
|
16
21
|
const user = await User.create({ name, email, passwordDigest })
|
|
17
|
-
this.auth.login(user)
|
|
22
|
+
await this.auth.login(user)
|
|
18
23
|
return this.redirect('{{URL_PREFIX}}/dashboard')
|
|
19
24
|
},
|
|
20
25
|
})
|
|
@@ -13,7 +13,7 @@ export default controller({
|
|
|
13
13
|
const user = await User.where('email', '=', email).first()
|
|
14
14
|
// passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
|
|
15
15
|
if (user && (await verifyPassword(password, user.passwordDigest))) {
|
|
16
|
-
this.auth.login(user)
|
|
16
|
+
await this.auth.login(user)
|
|
17
17
|
return this.redirect('{{URL_PREFIX}}/dashboard')
|
|
18
18
|
}
|
|
19
19
|
return this.render('Auth/Login', {
|
|
@@ -22,7 +22,7 @@ export default controller({
|
|
|
22
22
|
},
|
|
23
23
|
// DELETE {{URL_PREFIX}}/session — 로그아웃
|
|
24
24
|
async destroy() {
|
|
25
|
-
this.auth.logout()
|
|
25
|
+
await this.auth.logout()
|
|
26
26
|
return this.redirect('{{URL_PREFIX}}/session/new')
|
|
27
27
|
},
|
|
28
28
|
})
|
|
@@ -35,12 +35,12 @@ export default controller({
|
|
|
35
35
|
error: '이 앱에 접근할 권한이 없습니다.' as string | null,
|
|
36
36
|
})
|
|
37
37
|
}
|
|
38
|
-
this.auth.login(user)
|
|
38
|
+
await this.auth.login(user)
|
|
39
39
|
return this.redirect('{{URL_PREFIX}}/dashboard')
|
|
40
40
|
},
|
|
41
41
|
// DELETE {{URL_PREFIX}}/session — 로그아웃
|
|
42
42
|
async destroy() {
|
|
43
|
-
this.auth.logout()
|
|
43
|
+
await this.auth.logout()
|
|
44
44
|
return this.redirect('{{URL_PREFIX}}/session/new')
|
|
45
45
|
},
|
|
46
46
|
})
|
|
@@ -5,7 +5,7 @@ import { table, t } from 'gaonjs/data'
|
|
|
5
5
|
export const users = table('users', {
|
|
6
6
|
id: t.id(),
|
|
7
7
|
name: t.string().max(100),
|
|
8
|
-
email: t.string().max(255),
|
|
8
|
+
email: t.string().max(255).unique(), // 유니크 — 중복 가입 방지(로그인 .first() 결정성 보장)
|
|
9
9
|
passwordDigest: t.string().hidden(), // hidden — 페이지로 새지 않는다(§4.2)
|
|
10
10
|
...t.timestamps(),
|
|
11
11
|
})
|
|
@@ -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
|
+
- **워커 복원력(결정 258)** — 재시도 재적재나 DLQ 이관을 하는 도중 NATS 가
|
|
90
|
+
순단해 발행 자체가 실패해도, 워커의 큐 소비 루프는 **멈추지 않는다**. 그 잡은
|
|
91
|
+
ack/DLQ 하지 않고 되돌려(재전달 백스톱) 유실을 막고, 실패는 로그로 남긴다 —
|
|
92
|
+
한 잡의 인프라 순단이 큐 전체를 조용히 세우지 않는다.
|
|
89
93
|
- **실행 컨텍스트(결정 129)** — `gaon work` 는 `gaon serve` 와 **같은 도메인
|
|
90
94
|
배선**(DB 커넥션 · 메일)을 태운다. 그래서 잡 핸들러에서 모델 조회(`User.find`),
|
|
91
95
|
`Mail.deliver`, `broadcast`, 다른 잡 `.later()` 를 웹 컨트롤러에서와 똑같이 쓴다
|
|
@@ -413,4 +417,5 @@ async create() {
|
|
|
413
417
|
| 결정 230 | 스케줄러/앱 시간대 = `gaon.config.ts` 의 `timezone` → `process.env.TZ`(config 가 런치 `TZ` 보다 우선 · 앱 전역 단일 타임존 · §5) |
|
|
414
418
|
| 결정 233 | 크론 리더 페일오버 중복 발행 dedupe (결정론적 dedupe 키 + JetStream 중복 윈도우로 틱당 1회 발행 수렴 · §5) |
|
|
415
419
|
| 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
|
|
420
|
+
| 결정 258 | 워커 소비 루프 복원력(§1) — 재시도/DLQ 발행이 NATS 순단으로 실패해도 큐 소비가 멈추지 않음(nak 재전달 백스톱 · 잡 유실 방지 · 실패 로그) |
|
|
416
421
|
| §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
|
|
@@ -142,7 +142,10 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
|
|
|
142
142
|
**관계(`include`/지연) 로 로드한 대상 행에서도** 대상 테이블의 hidden 이
|
|
143
143
|
제외된다(결정 122). 즉 `Post.include('author')` 로 실은 `author`(User)를
|
|
144
144
|
render props 로 통째로 넘겨도 `passwordDigest` 는 나가지 않는다. hidden 값은
|
|
145
|
-
서버 코드에서는 그대로 읽힌다(직렬화에서만 제외 · §4.2).
|
|
145
|
+
서버 코드에서는 그대로 읽힌다(직렬화에서만 제외 · §4.2). hidden 마커는 **열거
|
|
146
|
+
가능한 심볼**이라 `{ ...user }` spread·`Object.assign` 을 넘어 보존된다(결정 253)
|
|
147
|
+
— 단 레코드를 손으로 재구성하거나 JSON 왕복하면 마커가 사라지니 그럴 땐 hidden
|
|
148
|
+
컬럼을 직접 넣지 않는다.
|
|
146
149
|
- `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
|
|
147
150
|
- `.index()` — 컬럼 레벨 인덱스 (E-4).
|
|
148
151
|
- `.check(expr)` — 컬럼 레벨 CHECK 제약 (E-4).
|
|
@@ -80,6 +80,12 @@ export default channel({
|
|
|
80
80
|
|
|
81
81
|
멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
|
|
82
82
|
|
|
83
|
+
**핸들러가 throw 하면 그 연결만 닫힌다(결정 257).** `onMessage`/`onLeave` 안에서
|
|
84
|
+
예외(코드 결함·DB 순단)가 나도 **서버는 죽지 않는다** — HTTP 액션이 500 으로
|
|
85
|
+
마감되는 것과 대칭으로, 그 연결에 에러 프레임을 보내고 닫는다(다른 연결·서버는
|
|
86
|
+
그대로 산다). 즉 `onMessage` 예외는 전역 장애가 아니라 연결 단위 장애다. 재시도가
|
|
87
|
+
필요한 로직은 핸들러 안에서 try/catch 로 감싸 직접 통제한다.
|
|
88
|
+
|
|
83
89
|
### 2.5 서버 개시 broadcast (결정 126)
|
|
84
90
|
|
|
85
91
|
`ctx.broadcast` 는 클라이언트 연결 훅(`onJoin`/`onMessage`/`onLeave`) **안에서만** 쓸 수 있다 —
|
|
@@ -233,6 +239,15 @@ export function useRoom(roomId: number) {
|
|
|
233
239
|
겹친다 — 인스턴스마다 다른 `GAON_HUB_PORT` 를 주거나 호스트를 분리한다.
|
|
234
240
|
`gaon dev` 는 내장 허브를 기본 포트로 띄우므로, 같은 호스트에서 별도
|
|
235
241
|
`gaon hub` 를 돌릴 땐 포트를 바꾼다.
|
|
242
|
+
- **디스커버리 endpoint 는 소유 리더만 지운다(결정 259).** standby 인스턴스의
|
|
243
|
+
정상 종료나 리더 교대가 **활성 리더의** 도달 주소(KV `gaon_hub/endpoint`)를
|
|
244
|
+
지우지 않는다 — 그렇지 않으면 재접속하는 웹서버가 허브를 못 찾고 무한
|
|
245
|
+
백오프에 빠진다. 삭제는 저장된 주소가 자기 것일 때 + revision CAS 로만
|
|
246
|
+
일어나, 롤링 재시작 중에도 디스커버리가 살아 있다.
|
|
247
|
+
- **리스 TTL 은 역할별로 독립이다(결정 260).** 허브(`GAON_HUB_TTL_MS`)와
|
|
248
|
+
스케줄러(고정 5s)는 서로 다른 KV 버킷(`gaon_lease_<역할>`)을 써 각자 TTL 을
|
|
249
|
+
가진다 — 한 버킷을 공유하던 시절의 TTL 플래핑(부팅 순서 의존)이 없다.
|
|
250
|
+
허브 TTL 을 튜닝해도 스케줄러 failover 타이밍에 영향을 주지 않는다.
|
|
236
251
|
|
|
237
252
|
## 정본 예시
|
|
238
253
|
|
|
@@ -311,6 +326,9 @@ export default channel({
|
|
|
311
326
|
| 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
|
|
312
327
|
| 결정 225 | 프레즌스 연결 축 refcount(§3) — 같은 멤버의 멀티탭·멀티서버 연결을 refcount 해 마지막 연결에서만 이탈 · cleanupServer 는 그 서버 연결만 회수(타서버 불간섭) |
|
|
313
328
|
| 결정 227 | 특정/다중 유저 타겟 발송(§2.6) — `sendToUsers(name, userIds, data)` · `broadcast` 와 대칭 · 대상 연결에만 전달(멀티서버·멀티탭) · 도달 유저 수 반환(오프라인=0) · 수정 1 연결 추적 위에 얹음 |
|
|
329
|
+
| 결정 257 | 채널 `onMessage`/`onLeave` throw 는 그 연결만 마감(§2.4) — 사용자 핸들러 예외가 unhandledRejection 으로 serve 를 죽이지 않는다(HTTP 500 대칭 · 에러 프레임 + 연결 종료) |
|
|
330
|
+
| 결정 259 | 허브 디스커버리 endpoint 는 소유 리더만 삭제(§5) — addr 일치 + revision CAS · standby 종료·리더 교대가 활성 endpoint 를 지우지 않음(재접속 서버 허브 발견 보존) |
|
|
331
|
+
| 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
|
|
314
332
|
|
|
315
333
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
316
334
|
|
|
@@ -51,6 +51,15 @@
|
|
|
51
51
|
자동으로 붙이고, 세션 secret 을 **앱별 env** `<APP>_SESSION_SECRET`(예:
|
|
52
52
|
`ADMIN_SESSION_SECRET`)로 분리 배선한다(결정 141 · 앱별 세션 완전 분리).
|
|
53
53
|
운영 배포 시 그 env 를 web 과 **다르게** 설정할 것.
|
|
54
|
+
- **운영 세션 secret 은 반드시 주입한다 (결정 255)**: app.config 는 dev 편의를 위해
|
|
55
|
+
`process.env.SESSION_SECRET ?? 'dev-only-session-secret-…'` 폴백을 각인한다. 운영
|
|
56
|
+
(`NODE_ENV=production`)에서 이 폴백/플레이스홀더가 세션 서명 secret 으로 쓰이면
|
|
57
|
+
**모든 배포가 같은 공개 secret 으로 세션을 서명**(위조 가능)하므로 부팅이 확정
|
|
58
|
+
종료된다(DB·PORT fail-loud 와 대칭). `compose.prod.yaml` 은 `SESSION_SECRET:?` 로
|
|
59
|
+
주입을 강제한다 — `openssl rand -base64 32` 로 만든 값을 env 로 넣는다.
|
|
60
|
+
- **로그인은 세션 ID 를 재생성하고 로그아웃은 세션을 파기한다 (결정 254)**:
|
|
61
|
+
`this.auth.login`/`logout` 이 자동 처리한다(session fixation 방어). 스캐폴드는
|
|
62
|
+
`await this.auth.login(user)` 형태다 — 손으로 세션을 조작하지 않는다.
|
|
54
63
|
- **비-web 앱은 시큐어 기본이다** (결정 155): `gaon g auth --app admin` 은
|
|
55
64
|
**공개 회원가입(registration)을 깔지 않는다** — 관리 앱에 공개 가입이 열리고
|
|
56
65
|
로그인한 일반 고객이 관리 화면을 보던 위험 기본을 구조적으로 막는다. 대신 보호
|
|
@@ -45,6 +45,11 @@ export default controller({
|
|
|
45
45
|
반환해도 안전하다(손 직렬화 불필요). 드물게 응답 경계 **밖**(예: 채널 broadcast
|
|
46
46
|
페이로드, 커스텀 문자열 응답)에서 같은 규칙으로 직렬화하고 싶으면 직접 부른다:
|
|
47
47
|
`import { serializeProps } from 'gaonjs/web'`.
|
|
48
|
+
- **hidden 은 spread 를 넘어 보존된다(결정 253)** — `render(page, { ...user })` 나
|
|
49
|
+
`{ user: { ...row } }` 처럼 모델 레코드를 펼쳐도 hidden 마커가 함께 복제돼
|
|
50
|
+
`passwordDigest` 는 여전히 제외된다. 단, 레코드를 **손으로 재구성**하거나
|
|
51
|
+
(`{ id: u.id, email: u.email }`) `JSON.parse(JSON.stringify(u))` 로 왕복하면
|
|
52
|
+
마커가 사라진다 — 그 경우 hidden 컬럼을 직접 넣지 말고 필요한 필드만 골라 넘긴다.
|
|
48
53
|
- **`this.render` 인자** = `<PageFolder>/<Page>` (PascalCase 폴더 · Vue
|
|
49
54
|
파일명과 정합) — 예: `'Posts/Index'` → `apps/<app>/pages/Posts/Index.vue`.
|
|
50
55
|
- **리소스 부재 = `this.notFound()`** — 조회 결과가 없으면 404 를 손으로 만들지
|
|
@@ -429,8 +434,11 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
429
434
|
| 호출 | 하는 일 |
|
|
430
435
|
|---|---|
|
|
431
436
|
| `this.auth.user` | 현재 사용자 (`GaonCurrentUser \| null`) |
|
|
432
|
-
| `this.auth.login(user)` |
|
|
433
|
-
| `this.auth.logout()` |
|
|
437
|
+
| `await this.auth.login(user)` | 세션 ID 를 **재생성**하고 사용자 id 를 심어 로그인 상태로 만든다(fixation 방어 · 결정 254) |
|
|
438
|
+
| `await this.auth.logout()` | 세션을 **파기**한다(잔존 세션 재사용 차단 · 결정 254) |
|
|
439
|
+
|
|
440
|
+
`login`/`logout` 은 세션 ID 재생성·파기(비동기)를 하므로 `await` 를 붙인다. 생략해도
|
|
441
|
+
디스패처가 응답 직전에 정착시켜 동작하지만(기존 코드 호환), 정본은 `await` 다.
|
|
434
442
|
|
|
435
443
|
```ts
|
|
436
444
|
// 로그인 — 폼 액션의 유일한 render+redirect 혼용 예외(결정 57 보완)
|
|
@@ -441,12 +449,12 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
441
449
|
// csrf 는 자동 주입 공유 prop 이므로 컨트롤러가 넘기지 않는다(결정 116·117)
|
|
442
450
|
return this.render('Auth/Login', { error: '이메일 또는 비밀번호가 올바르지 않습니다.' })
|
|
443
451
|
}
|
|
444
|
-
this.auth.login(user)
|
|
452
|
+
await this.auth.login(user) // 세션 ID 재생성 + 로그인 확정(결정 254)
|
|
445
453
|
return this.redirect('/dashboard')
|
|
446
454
|
}
|
|
447
455
|
// 로그아웃 — DELETE /session (페이지에서 router.delete 로 호출 · 결정 64)
|
|
448
456
|
async destroy() {
|
|
449
|
-
this.auth.logout()
|
|
457
|
+
await this.auth.logout() // 세션 파기(결정 254)
|
|
450
458
|
return this.redirect('/session/new')
|
|
451
459
|
}
|
|
452
460
|
```
|
|
@@ -19,6 +19,10 @@ x-app-env: &app-env
|
|
|
19
19
|
REDIS_URL: redis://redis:6379
|
|
20
20
|
NATS_URL: nats://nats:4222
|
|
21
21
|
COOKIE_SECRET: ${COOKIE_SECRET:?set COOKIE_SECRET (32+ chars)}
|
|
22
|
+
# 세션 서명 secret — 운영에서 반드시 주입한다(미주입 시 스캐폴드의 dev 폴백
|
|
23
|
+
# secret 으로 세션을 서명하게 되어 위조 가능). app.config 의 session.secret 이
|
|
24
|
+
# 이 값을 읽는다. 앱별 세션(gaon g auth)은 <APP>_SESSION_SECRET 을 추가로 주입.
|
|
25
|
+
SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET (32+ chars)}
|
|
22
26
|
NODE_ENV: production
|
|
23
27
|
|
|
24
28
|
services:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.7",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/async": "0.15.
|
|
31
|
-
"@gaonjs/
|
|
32
|
-
"@gaonjs/
|
|
33
|
-
"@gaonjs/data": "0.17.
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/
|
|
36
|
-
"@gaonjs/
|
|
30
|
+
"@gaonjs/async": "0.15.2",
|
|
31
|
+
"@gaonjs/config": "0.17.6",
|
|
32
|
+
"@gaonjs/core": "0.2.4",
|
|
33
|
+
"@gaonjs/data": "0.17.1",
|
|
34
|
+
"@gaonjs/i18n": "0.2.3",
|
|
35
|
+
"@gaonjs/mail": "0.3.1",
|
|
36
|
+
"@gaonjs/web": "0.20.1"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|