@gaonjs/cli 0.43.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/check.d.ts +1 -1
- package/dist/commands/check.js +1 -1
- package/dist/commands/db.js +29 -7
- package/dist/commands/new.d.ts +2 -0
- package/dist/commands/new.js +8 -3
- package/dist/commands/test.js +16 -3
- package/dist/db/journal.d.ts +4 -3
- package/dist/db/journal.js +21 -10
- package/dist/db/migrate.d.ts +3 -1
- package/dist/db/migrate.js +3 -3
- package/dist/db/replay.js +2 -2
- package/dist/db/resolve.d.ts +15 -0
- package/dist/db/resolve.js +24 -2
- package/dist/db/status.js +13 -3
- package/dist/dev.d.ts +6 -4
- package/dist/dev.js +9 -4
- package/dist/doctor/fixers/index.d.ts +1 -1
- package/dist/doctor/fixers/index.js +6 -1
- package/dist/doctor/locale-parity.js +4 -1
- package/dist/doctor/pageprops-destructure.d.ts +2 -2
- package/dist/doctor/pageprops-destructure.js +29 -23
- package/dist/doctor/render-return.d.ts +11 -0
- package/dist/doctor/render-return.js +143 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +3 -2
- package/dist/doctor.js +17 -6
- package/dist/generate.d.ts +20 -1
- package/dist/generate.js +120 -21
- package/dist/hub.js +2 -0
- package/dist/i18n-config.d.ts +12 -0
- package/dist/i18n-config.js +95 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +59 -14
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +9 -6
- package/dist/messages-gen.d.ts +1 -1
- package/dist/messages-gen.js +5 -2
- package/dist/scaffold/job.js +3 -1
- package/dist/templates/auth/Dashboard.vue.tpl +3 -2
- package/dist/templates/auth/Login.vue.tpl +3 -5
- package/dist/templates/auth/Signup.vue.tpl +3 -5
- package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
- package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
- package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
- package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
- package/dist/templates/project/.dockerignore.tpl +3 -0
- package/dist/templates/project/.env.example.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +5 -3
- package/dist/templates/project/CLAUDE.md.tpl +5 -4
- package/dist/templates/project/Dockerfile.tpl +6 -1
- package/dist/templates/project/agents/async.md.tpl +75 -10
- package/dist/templates/project/agents/data.md.tpl +159 -22
- package/dist/templates/project/agents/frontend.md.tpl +51 -12
- package/dist/templates/project/agents/i18n.md.tpl +5 -2
- package/dist/templates/project/agents/mail.md.tpl +4 -2
- package/dist/templates/project/agents/realtime.md.tpl +37 -3
- package/dist/templates/project/agents/seal.md.tpl +14 -3
- package/dist/templates/project/agents/security.md.tpl +47 -11
- package/dist/templates/project/agents/storage.md.tpl +16 -8
- package/dist/templates/project/agents/web.md.tpl +78 -24
- package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
- package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
- package/dist/templates/project/apps/web/main.ts.tpl +1 -1
- package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
- package/dist/templates/project/docker-compose.yaml.tpl +1 -1
- package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
- package/dist/templates/project/vite.config.ts.tpl +1 -1
- package/dist/work.d.ts +21 -0
- package/dist/work.js +45 -1
- package/package.json +12 -7
- package/dist/templates/index.ts +0 -109
|
@@ -32,6 +32,28 @@
|
|
|
32
32
|
realtime 허용). 끄거나 조정은 `gaon.config.ts` 의 `web.security.securityHeaders` 로
|
|
33
33
|
— `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
|
|
34
34
|
`helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
|
|
35
|
+
- **rate limit 은 앱 라우트뿐 아니라 정적 에셋(`/assets/*`)·static 폴백까지 전
|
|
36
|
+
라우트에 적용된다**(기본 100 req/분/IP). 에셋이 많은 페이지를 여러
|
|
37
|
+
사용자가 한 IP(NAT·사내망) 뒤에서 열면 기본치에 닿을 수 있다 — 그 경우
|
|
38
|
+
`web.security.rateLimit: { max: ... }` 로 상향한다(끄지 말고 조정).
|
|
39
|
+
- **CORS·rate limit 은 앱 스코프 단위로 override 할 수 있다 (결정 339).** 전역
|
|
40
|
+
(`gaon.config.ts` 의 `web.security`)이 기본이고, 앱이 `app.config.ts` 의
|
|
41
|
+
`security: { cors, rateLimit }` 로 자기 것만 바꾼다 — **API 앱만 크로스 오리진을
|
|
42
|
+
열어도 web 앱의 same-origin 기본은 그대로다**(앱별 세션 분리와 같은 축):
|
|
43
|
+
```ts
|
|
44
|
+
// apps/api/app.config.ts
|
|
45
|
+
export default defineAppConfig({
|
|
46
|
+
security: { cors: { origin: ['https://app.example.com'] }, rateLimit: { max: 600 } },
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
생략한 필드는 전역 상속 · `false` 는 그 앱에서만 끔(명시적으로만 · 규칙 8).
|
|
50
|
+
rate limit 카운터는 **앱 단위 버킷**이다(override 여부와 무관 — 한 클라이언트가
|
|
51
|
+
web·api 를 함께 써도 상한은 앱별로 센다). 보안 응답 헤더는 전역 전용(앱
|
|
52
|
+
override 없음).
|
|
53
|
+
- **기본 CSP 의 `connect-src 'self' ws: wss:` 는 스킴 와일드카드다** — realtime
|
|
54
|
+
기본 지원을 위해 임의 오리진 WebSocket 이 허용된다(XSS 성립 시 exfil 채널이
|
|
55
|
+
될 수 있는 트레이드오프). 더 조이려면 `web.security.securityHeaders.
|
|
56
|
+
contentSecurityPolicy` 로 자기 호스트(`wss://app.example.com`)만 명시한다.
|
|
35
57
|
- **스토리지 오리진 CSP 자동 배선 (결정 131)** — `gaon.config.ts` 의 storage(S3/R2/MinIO)
|
|
36
58
|
설정이 있으면 코어가 그 오리진을 CSP 의 **img-src**(스토리지 이미지 `<img>` 표시)·
|
|
37
59
|
**connect-src**(브라우저 직접 presigned 업로드/다운로드)에 자동으로 더한다(seal→
|
|
@@ -77,17 +99,17 @@
|
|
|
77
99
|
- CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF 강제.
|
|
78
100
|
**토큰은 `<meta>` 태그가 아니라 data-page 공유 prop 으로 온다 (결정 116).**
|
|
79
101
|
프레임웍 Inertia 셸은 `<meta name="csrf-token">` 을 **넣지 않는다** — csrf 는
|
|
80
|
-
모든 렌더에 자동 주입되는 공유 prop
|
|
81
|
-
(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
102
|
+
모든 렌더에 자동 주입되는 공유 prop 이다(`packages/vue/src/shared.ts`).
|
|
103
|
+
**부착은 프레임웍 자동이다(결정 341·342 · The One Way)**: `useForm`/`router` 의
|
|
104
|
+
상태 변경 제출과 `api()` 상태 변경 호출 전부에 **라이브 Inertia 페이지 props 의
|
|
105
|
+
csrf**(`useShared().csrf` 와 같은 단일 출처 · `packages/vue/src/csrf.ts`)가
|
|
106
|
+
`X-CSRF-Token` 헤더로 자동 실린다 — 페이지가 `_csrf` 바디·수동 헤더를 싣지
|
|
107
|
+
않는다(스캐폴드 Login/Signup/Dashboard 동기). 라이브 출처라 로그인(세션 재생성 ·
|
|
108
|
+
결정 254) 직후에도 항상 최신 토큰이다 — 종전의 "최초 문서 스냅샷 stale → 로그인
|
|
109
|
+
후 api() 403" 한계는 결정 341 로 봉합됐다(최초 문서 data-page·레거시 meta 는
|
|
110
|
+
부팅 전 폴백으로만 남는다). `useForm`/`router`/`api()` 를 우회하는 커스텀 전송은
|
|
111
|
+
`readCsrfToken()`(gaonjs/vue)으로 토큰을 읽어 직접 실어라. JWT/API 앱은 토큰
|
|
112
|
+
인증이라 CSRF 대상이 아니다(토큰 없으면 아무것도 안 붙는 무회귀 경로).
|
|
91
113
|
- **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
|
|
92
114
|
세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
|
|
93
115
|
`app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
|
|
@@ -106,6 +128,12 @@
|
|
|
106
128
|
같은 핸들러가 Inertia-네이티브 에러+수리 안내로 마감한다. 비-Inertia(API/JWT)는 종전
|
|
107
129
|
JSON 유지(회귀 없음). 상세는 `agents/web.md` §4.1. 앱은 아무것도 안 한다.
|
|
108
130
|
- JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
|
|
131
|
+
- **JWT 하드닝 (결정 337)**: secret **32자 미만 = 부팅 에러**(항상) · 운영에서
|
|
132
|
+
dev 폴백/플레이스홀더 secret = 부팅 확정 종료(세션 결정 255 와 대칭) · 검증은
|
|
133
|
+
**HS256 alg 고정**(algorithm confusion 방어). 스캐폴드는 `gaon g auth --jwt
|
|
134
|
+
--app <api>`(결정 338) — `.env` 의 `<APP>_JWT_SECRET` 으로 주입한다.
|
|
135
|
+
- **폐기 한계**: 토큰은 stateless — 서버측 폐기(로그아웃·강제 무효화)가 없다.
|
|
136
|
+
유출 리프레시 토큰은 만료까지 유효 · 민감 앱은 `refreshTtl` 단축(v1 범위 밖).
|
|
109
137
|
- **인증·인가는 3층이다** (결정 145 · 149):
|
|
110
138
|
- **① 인증 `this.requireAuth()`** = **로그인 여부** — 비로그인이면 401(세션 앱은
|
|
111
139
|
로그인 페이지 리다이렉트).
|
|
@@ -249,3 +277,11 @@ const rows = await Post.query()
|
|
|
249
277
|
| 결정 165 | 세션/CSRF 실패·415 를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 무 · 비-Inertia 는 JSON 유지(§2.3) |
|
|
250
278
|
| 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§2.3 · `this.auth`) |
|
|
251
279
|
| 결정 271 | CSRF 를 끄는 유일 스위치 = `session: { csrf: false }` — 생략은 켬(보안 기본값) · wire 가 `SessionOptions.csrf` 로 전달(§2.3) |
|
|
280
|
+
| 결정 295 | 세션 쿠키 `secure`/`sameSite` 를 app.config session 으로 조정(wire 전달) · `sameSite:'none'`+secure 미충족 = 부팅 에러(브라우저 조용한 쿠키 거부 방지) |
|
|
281
|
+
| 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw — 조용한 no-op(부팅 green·로그인 영구 실패) 금지 |
|
|
282
|
+
| 결정 297 | Inertia HTML/JSON 응답에 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — 공유 캐시가 사용자별 페이지(csrf·currentUser)를 저장·교차 서빙하지 못하게 |
|
|
283
|
+
| 결정 337 | JWT 하드닝 — secret 32자 하한 · 운영 dev 폴백 부팅 거부 · 검증 alg HS256 고정 · stateless 폐기 한계 명기(§2) |
|
|
284
|
+
| 결정 338 | `gaon g auth --jwt` — API 앱 토큰 스캐폴드(`<APP>_JWT_SECRET` 시드 · 공개 가입 없음 · `agents/web.md` §6) |
|
|
285
|
+
| 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }` · 전역 상속 · 앱 단위 rate limit 버킷 · 보안 헤더는 전역 전용(§1) |
|
|
286
|
+
| 결정 341 | CSRF 토큰 출처 = **라이브 Inertia 페이지 props**(최초 문서 data-page·meta 는 부팅 전 폴백) — 로그인 세션 재생성(결정 254) 후 stale 403 봉합(`packages/vue/src/csrf.ts` · §2) |
|
|
287
|
+
| 결정 342 | CSRF 부착 The One Way — `useForm`/`router`/`api()` 상태 변경에 `X-CSRF-Token` 자동 부착 · 수동 `_csrf` 바디/헤더 제거(스캐폴드 동기) · 우회 전송은 `readCsrfToken()` 탈출구(§2) |
|
|
@@ -23,8 +23,12 @@
|
|
|
23
23
|
| 디스크 선택 | `Storage.disk('s3').put(...)` | 기본 디스크 외 다른 디스크로 |
|
|
24
24
|
|
|
25
25
|
- **URL 은 `Storage.url()` 한 곳**이지만 **드라이버로 갈린다**:
|
|
26
|
-
- **로컬 디스크**: 항상 `${
|
|
27
|
-
`
|
|
26
|
+
- **로컬 디스크**: 항상 `${publicUrl}/${key}` **공개 경로**를 만든다(서명 없음 · `expiresIn` 무시 ·
|
|
27
|
+
`publicUrl` 생략 시 `/storage`) — presigned 개념이 없다. **프레임웍이 이 경로를 직접 서빙한다**
|
|
28
|
+
(결정 355 · `gaon serve`/`gaon dev` 의 루트에 자동 등록 — 상대 publicUrl 만 · 폴더 이탈 차단).
|
|
29
|
+
업로드→`url()` 렌더→표시가 zero-config 로 흐른다. publicUrl 이 절대 URL(별도 서버/CDN)이면
|
|
30
|
+
서빙하지 않는다(그 서버 몫). 공개 서빙이라 **비공개 파일은 로컬 디스크 공개 경로에 두지 말
|
|
31
|
+
것**(접근 제어가 필요하면 s3 presigned 또는 컨트롤러 라우트).
|
|
28
32
|
- **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public)이 있으면 `${publicUrl}/${key}`,
|
|
29
33
|
없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
|
|
30
34
|
존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
|
|
@@ -57,9 +61,12 @@ storage: process.env.STORAGE_ENDPOINT
|
|
|
57
61
|
```
|
|
58
62
|
|
|
59
63
|
- dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
|
|
60
|
-
(결정 132 · zero-config). `
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
(결정 132 · zero-config). `.env` 는 `gaon new` 가 자동 생성하므로(결정 198)
|
|
65
|
+
`gaon dev` 만으로 우회 0.
|
|
66
|
+
- 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage' }` — `url(key)`
|
|
67
|
+
= `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 프레임웍이 자동 서빙 — 결정 355).
|
|
68
|
+
config 필드명은 `publicUrl` 이다(저수준 `localDisk()` 의 `baseUrl` 과 다름 — `baseUrl` 을 config 에
|
|
69
|
+
쓰면 컴파일 에러).
|
|
63
70
|
- 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
|
|
64
71
|
만 env 로 바꾼다.
|
|
65
72
|
|
|
@@ -90,9 +97,10 @@ export default controller({
|
|
|
90
97
|
### 4. 스토리지 오리진 CSP 자동 배선 (결정 131)
|
|
91
98
|
|
|
92
99
|
`storage` 에 s3 디스크가 있으면 프레임웍이 그 오리진을 **CSP 에 자동 배선**한다 —
|
|
93
|
-
`endpoint` 는 `connect-src`(
|
|
94
|
-
`
|
|
95
|
-
|
|
100
|
+
`endpoint` 는 `connect-src`(브라우저의 presigned **GET** 조회 — presign 표면은 GET 전용이고
|
|
101
|
+
브라우저 직접 PUT 업로드 API 는 없다 · 업로드는 멀티파트 `this.file` 경로가 정본)와 `img-src` 에,
|
|
102
|
+
`publicUrl` 은 `img-src` 에 붙는다. 이미지 표시가 CSP 로 막히지 않으므로
|
|
103
|
+
**CSP 를 손으로 넓히지 말 것**. 오리진은 scheme+host+port 만 잡는다.
|
|
96
104
|
|
|
97
105
|
### 5. 스토리지를 쓰는 잡·테스트
|
|
98
106
|
|
|
@@ -103,6 +103,11 @@ export default controller({
|
|
|
103
103
|
- `?tag=a&tag=b` 처럼 같은 출처에서 키가 중복되면: 스키마의 해당
|
|
104
104
|
필드가 배열 타입이면 배열로 수집, 아니면 **마지막 값**을 쓴다
|
|
105
105
|
(E-3 §5.2 원문).
|
|
106
|
+
- **애드혹 폼(`{ _row }`)은 last-wins 가 적용되지 않는다(결정 294)** —
|
|
107
|
+
스키마(defs)가 없어 배열/스칼라 의도를 구분할 수 없으므로 중복 쿼리 키는
|
|
108
|
+
**배열 그대로** 통과한다(`?q=a&q=b` → `['a','b']`). 애드혹 폼 타입을
|
|
109
|
+
`string` 으로만 선언하면 배열이 들어와 런타임이 어긋난다 — 배열 가능성이
|
|
110
|
+
있는 키는 `string | string[]` 로 선언하거나 스키마 파생 폼(①)으로 간다.
|
|
106
111
|
|
|
107
112
|
**출처 명시 탈출구 — `this.body()` / `this.query()`:**
|
|
108
113
|
|
|
@@ -163,35 +168,32 @@ export default controller({
|
|
|
163
168
|
①이 검증까지 주므로 **모델이 있으면 ①을 먼저 고른다**. ②는 로그인 폼처럼
|
|
164
169
|
전용 테이블이 없는 입력의 탈출구다.
|
|
165
170
|
|
|
166
|
-
**멀티파트 업로드 + CSRF —
|
|
171
|
+
**멀티파트 업로드 + CSRF — 서버 검사는 `x-csrf-token` 헤더로만 (결정 133 · 부착은 자동 · 결정 342):**
|
|
167
172
|
|
|
168
|
-
`this.file()` 업로드(멀티파트)의 CSRF
|
|
173
|
+
`this.file()` 업로드(멀티파트)의 CSRF 검사는 **`x-csrf-token` 헤더**만 본다.
|
|
169
174
|
멀티파트는 `parts()` 스트리밍이라 CSRF 검사(preHandler) 시점에 **바디가 아직
|
|
170
175
|
파싱되지 않아** 폼 필드 `_csrf` 가 검사에 잡히지 않는다(구조적 한계 · 디스패처가
|
|
171
|
-
handler 안에서 파싱).
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
`application/json` · `multipart/form-data` 뿐이라
|
|
175
|
-
|
|
176
|
+
handler 안에서 파싱). **결정 342 이후 헤더 부착은 프레임웍 자동**이다 —
|
|
177
|
+
`useForm`/`router` 의 상태 변경 제출(멀티파트 포함)에 최신 토큰이
|
|
178
|
+
`X-CSRF-Token` 으로 자동 실리므로 업로드 폼도 손으로 헤더를 넘기지 않는다.
|
|
179
|
+
(참고: 지원 Content-Type 은 `application/json` · `multipart/form-data` 뿐이라
|
|
180
|
+
`x-www-form-urlencoded` 로 폼을 보내면 415 로 거부된다 · `inertia.ts` · §아래 415.)
|
|
176
181
|
|
|
177
182
|
```vue
|
|
178
183
|
<script setup lang="ts">
|
|
179
|
-
import { useForm
|
|
180
|
-
const shared = useShared() // csrf 는 자동 주입 공유 prop (결정 116)
|
|
184
|
+
import { useForm } from 'gaonjs/vue'
|
|
181
185
|
const form = useForm({ avatar: null as File | null })
|
|
182
186
|
|
|
183
187
|
function submit() {
|
|
184
|
-
// 파일이 있으면 multipart —
|
|
185
|
-
form.post('/uploads', { headers: { 'x-csrf-token': shared.csrf } })
|
|
188
|
+
form.post('/uploads') // 파일이 있으면 multipart — CSRF 헤더는 자동(결정 342)
|
|
186
189
|
}
|
|
187
190
|
</script>
|
|
188
191
|
```
|
|
189
192
|
|
|
190
|
-
- **알려진 함정:** 업로드
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
예시). 멀티파트일 때만 헤더가 유일 경로다.
|
|
193
|
+
- **알려진 함정:** 업로드 폼에 `useForm({ avatar, _csrf: ... })` 처럼 `_csrf` 를
|
|
194
|
+
바디 필드로 넣어도 멀티파트에선 **검사 시점에 없어 무의미**하다(자동 헤더가
|
|
195
|
+
실질 경로). 커스텀 fetch 업로더처럼 `useForm`/`router` 를 우회하면 자동 부착이
|
|
196
|
+
없다 — `readCsrfToken()`(gaonjs/vue)으로 토큰을 읽어 헤더에 직접 실어라.
|
|
195
197
|
|
|
196
198
|
### 4. 데이터 경로 판단 — 루트 판단표가 정본
|
|
197
199
|
|
|
@@ -208,14 +210,13 @@ redirect 로 처리한다 — 전체 페이지 리로드도, 별도 REST 엔드
|
|
|
208
210
|
```ts
|
|
209
211
|
// 로그인 폼 — 제출은 Inertia SPA 방식, 서버는 redirect 로 답한다.
|
|
210
212
|
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
211
|
-
//
|
|
213
|
+
// CSRF 토큰은 프레임웍이 자동 부착한다(결정 342) — _csrf 바디도, 수동 헤더도 없다.
|
|
212
214
|
const props = pageProps<'web:session#new'>()
|
|
213
|
-
const
|
|
214
|
-
const form = useForm({ email: '', password: '', _csrf: shared.csrf })
|
|
215
|
+
const form = useForm({ email: '', password: '' })
|
|
215
216
|
// <form @submit.prevent="form.post('/session')"> · 실패 시 {{ props.error }} 가 반응형으로 갱신
|
|
216
217
|
|
|
217
|
-
// HTML <form> 이 못 보내는 메서드(DELETE 등)는 router 로
|
|
218
|
-
router.delete('/session'
|
|
218
|
+
// HTML <form> 이 못 보내는 메서드(DELETE 등)는 router 로 보낸다(CSRF 자동).
|
|
219
|
+
router.delete('/session')
|
|
219
220
|
```
|
|
220
221
|
|
|
221
222
|
`?_method=DELETE` 같은 우회는 **서버가 해석하지 않는다** — POST 로 나가
|
|
@@ -365,8 +366,10 @@ shared.locale // 로그인/로그아웃·플래시로
|
|
|
365
366
|
// ❌ 컨트롤러가 검색 교집합·태그 필터를 인라인 조립
|
|
366
367
|
// ✅ const rows = await Post.searchPublished(term).latest().offset(o).limit(n).all()
|
|
367
368
|
// ✅ 페이지네이션은 스코프 체인 종단 paginate — 컨트롤러는 여전히 한 줄(결정 119)
|
|
368
|
-
//
|
|
369
|
-
//
|
|
369
|
+
// (결정 294: this.query('page') 같은 단일 키 접근 표면은 없다 — 애드혹 폼으로 받는다)
|
|
370
|
+
// const { page } = this.query({ _row: {} as { page?: string } })
|
|
371
|
+
// const result = await Post.searchPublished(term).latest().paginate(Number(page ?? 1), 20)
|
|
372
|
+
// return this.render('Posts/Index', { page: result }) // 통째로 안전(rows Serialized · 나머지 number)
|
|
370
373
|
```
|
|
371
374
|
|
|
372
375
|
### 4.4 클라이언트 IP · 헤더는 `this.request` (FastifyRequest 탈출구 · 결정 120)
|
|
@@ -422,6 +425,12 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
422
425
|
서명 secret · Redis 키 prefix 가 앱 단위로 갇힌다. (쿠키 path 는 `/` 고정 —
|
|
423
426
|
프리픽스·서브도메인 양쪽 접근에 쿠키가 실리려면 정적 path 가 `/` 여야 한다 ·
|
|
424
427
|
결정 142. 분리는 위 세 축으로 완성된다.)
|
|
428
|
+
- **쿠키 secure·sameSite 는 app.config 의 session 에서 조정한다(결정 295).**
|
|
429
|
+
`secure` 생략 시 운영(NODE_ENV=production)이면 켬 — 운영인데 비-TLS(사내
|
|
430
|
+
내부망 http)로 서빙하면 Secure 쿠키가 안 실려 로그인이 조용히 실패하므로 그
|
|
431
|
+
경우에만 `secure: false` 를 명시한다. `sameSite` 는 기본 `'lax'` —
|
|
432
|
+
`'none'` 은 스펙상 Secure 필수라 `secure: true` 없이 쓰면 부팅 에러다
|
|
433
|
+
(브라우저의 조용한 쿠키 거부를 fail-loud 로 전환).
|
|
425
434
|
- 스캐폴드는 `gaon g auth` — 로그인/회원가입 컨트롤러·페이지·라우트 일습.
|
|
426
435
|
- **로그인 필요 액션의 정답 = `this.requireAuth()`** (결정 57). notFound 처럼
|
|
427
436
|
예외로 마감하지만, **`if` 가드 자체가 없다**는 게 핵심:
|
|
@@ -447,6 +456,11 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
447
456
|
| `await this.auth.login(user)` | 세션 ID 를 **재생성**하고 사용자 id 를 심어 로그인 상태로 만든다(fixation 방어 · 결정 254) |
|
|
448
457
|
| `await this.auth.logout()` | 세션을 **파기**한다(잔존 세션 재사용 차단 · 결정 254) |
|
|
449
458
|
|
|
459
|
+
세션이 없는 앱(세션 미구성·JWT/API)에서 `login`/`logout` 을 부르면 **즉시
|
|
460
|
+
throw(500 + 수리 안내)** 한다(결정 296) — 이전엔 조용한 no-op 이라 부팅
|
|
461
|
+
green·로그인만 영구 실패였다. 세션 앱이면 app.config 에 session 을 배선하고,
|
|
462
|
+
JWT 앱이면 `this.jwt.issue`/`this.jwt.refresh` 를 쓴다.
|
|
463
|
+
|
|
450
464
|
`login`/`logout` 은 세션 ID 재생성·파기(비동기)를 하므로 `await` 를 붙인다. 생략해도
|
|
451
465
|
디스패처가 응답 직전에 정착시켜 동작하지만(기존 코드 호환), 정본은 `await` 다.
|
|
452
466
|
|
|
@@ -471,6 +485,21 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
471
485
|
|
|
472
486
|
API 앱(JWT)은 세션 대신 `this.jwt.issue(user)` / `this.jwt.refresh(token)` 를 쓴다.
|
|
473
487
|
|
|
488
|
+
- **JWT 하드닝 (결정 337 · 세션 결정 255 와 대칭).** JWT secret 은 **32자 이상**이
|
|
489
|
+
부팅 요건이고(미만 = 부팅 에러), 운영(NODE_ENV=production)에서 dev 폴백/
|
|
490
|
+
플레이스홀더(`dev-only-…`·`change-me…`)가 남아 있으면 부팅이 확정 종료된다.
|
|
491
|
+
검증은 **HS256 으로 alg 고정** — 같은 키의 HS384/512 서명·헤더 alg 조작은
|
|
492
|
+
통하지 않는다. **알려진 한계**: 토큰은 stateless 라 서버측 폐기(로그아웃·강제
|
|
493
|
+
무효화) 수단이 없다 — 유출된 리프레시 토큰은 만료(기본 7d)까지 유효하므로
|
|
494
|
+
민감한 앱은 `refreshTtl` 을 짧게 잡는다(서버측 폐기 목록은 v1 범위 밖).
|
|
495
|
+
|
|
496
|
+
- **JWT 스캐폴드 = `gaon g auth --jwt --app <api>`** (결정 338 · `--app` 필수 ·
|
|
497
|
+
web 불가 — web 은 세션이 정본). 페이지·회원가입 없이 스키마/모델(공유) +
|
|
498
|
+
토큰 컨트롤러(JSON 전용: `POST /session` 발급 · `POST /session/refresh` 재발급 ·
|
|
499
|
+
`GET /session` 현재 사용자[Bearer]) + `auth: { strategy:'jwt', secret, loadUser }`
|
|
500
|
+
배선 + `.env` 에 `<APP>_JWT_SECRET` 시드를 깐다. 계정은 web 앱 가입 또는
|
|
501
|
+
seed 로 만든다(API 앱에 공개 가입 없음).
|
|
502
|
+
|
|
474
503
|
- **API 앱은 프론트엔드가 없다(JSON 전용).** `apps/<app>/app.config.ts`(`auth: { strategy:'jwt', … }`)
|
|
475
504
|
+ `routes.ts` + `controllers/` 만 두면 된다 — `index.html`·`main.ts`·`pages/` 는 만들지 않는다.
|
|
476
505
|
앱 발견은 `routes.ts` 기준이라 `gaon serve` 가 이 앱을 `/<app>` 프리픽스로 정상 마운트하고,
|
|
@@ -542,6 +571,20 @@ export default controller({
|
|
|
542
571
|
- **render/JSON/redirect 를 한 액션에서 조건 혼용하면 doctor
|
|
543
572
|
response-mixing 위반** — 액션을 나눈다. 예외는 폼 액션의
|
|
544
573
|
"실패 render + 성공 redirect" 조합 하나뿐(결정 57 보완).
|
|
574
|
+
- **JSON 액션 반환 객체의 예약 키(결정 294)** — 디스패처는 반환값을 모양으로
|
|
575
|
+
분기하므로 `redirect` 키를 가진 객체는 리다이렉트로, `json` 키는 this.json
|
|
576
|
+
결과로, `page`+`props` 조합은 렌더로 **오인**된다. JSON 응답 데이터의 최상위
|
|
577
|
+
키로 `redirect`·`json` 을 쓰거나 `page`·`props` 를 동시에 쓰지 말 것 —
|
|
578
|
+
필요하면 한 겹 감싼다(`return { data: { redirect: url } }`).
|
|
579
|
+
- **액션이 아무것도 반환하지 않으면 204 No Content 다** — `this.render(...)`
|
|
580
|
+
를 호출만 하고 `return` 을 빼먹으면 컴파일은 통과하고 페이지가 조용히
|
|
581
|
+
빈 204 로 나간다. 렌더·리다이렉트·JSON 은 항상 `return` 과 함께 쓴다.
|
|
582
|
+
`gaon doctor` 의 **render-return** 검사가 이 패턴(호출만 하고 return 누락)을
|
|
583
|
+
경고로 잡는다(결정 340 · 의도된 204 는 응답 호출 없이 그냥 return).
|
|
584
|
+
- **라우트 타깃 형식 불량은 부팅 에러다(결정 293)** — `r.get('/x', 'posts')`
|
|
585
|
+
처럼 `#액션` 을 빠뜨리면 이전엔 조용히 라우트가 사라져 무신호 404 였다.
|
|
586
|
+
이제 `routes()` 가 부팅에서 throw 한다(`'<컨트롤러>#<액션>'` 형식 필수).
|
|
587
|
+
라우트 표에 있는데 **구현 안 된 액션**은 종전대로 조용히 스킵된다(정상 경로).
|
|
545
588
|
- **`fetch()` 로 로그인 폼 구현 금지** — 세션 앱 폼은 `gaonjs/vue` 의
|
|
546
589
|
`useForm(...).post(...)`(결정 64). REST + fetch 는 API 앱(JWT) 전용.
|
|
547
590
|
- **컨트롤러에 비즈니스 로직 인라인 금지** (§5.3 One Way) — 여러 모델·
|
|
@@ -577,13 +620,24 @@ export default controller({
|
|
|
577
620
|
| 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps → 모든 렌더 자동 주입 · 코어 3종 예약(덮으면 throw) · 선언 병합 타입 · hidden 미유출 · useShared 로 읽기(§4.2) |
|
|
578
621
|
| 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
|
|
579
622
|
| 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
|
|
580
|
-
| 결정 133 | 멀티파트 업로드(`this.file()`) CSRF
|
|
623
|
+
| 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 검사는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내 · 부착은 결정 342 로 자동화) |
|
|
624
|
+
| 결정 342 | CSRF 부착 The One Way — `useForm`/`router` 상태 변경 visit 에 프레임웍이 `X-CSRF-Token` 자동 부착(라이브 페이지 props 출처 · 결정 341 · api() 와 단일 출처) · 수동 `_csrf` 바디/헤더 보일러플레이트 제거(스캐폴드 동기) · 명시 헤더는 존중(탈출구) |
|
|
581
625
|
| 결정 64 | 폼 API 는 `gaonjs/vue` 의 `useForm`·`router` 뿐 — 로그아웃 등 DELETE 는 `router.delete()`(`@inertiajs/vue3` 직접 import 금지 · `Inertia.post()` 유령 API 아님) |
|
|
582
626
|
| 결정 122 | 관계·hidden 값이 render 경계 `serializeProps` 를 넘어 새지 않는다 — hidden 컬럼 제외 유지(§4.2) |
|
|
583
627
|
| 결정 165 | 세션/CSRF 실패·415(지원 안 되는 Content-Type)를 코어가 Inertia-네이티브(409 풀 리로드+flash / 415 수리 안내)로 마감 — raw JSON 403 으로 앱을 깨지 않는다(§4.1 · 지원 타입 `application/json`·`multipart/form-data`) |
|
|
584
628
|
| 결정 183 | 검증 사유 로케일화 — 안정 코드 + 예약 namespace `validation.<code>` 로 요청 로케일 번역(미제공 시 내장 fallback · §4.1) |
|
|
585
629
|
| 결정 253 | hidden 마커 = 열거 가능한 심볼 → `render(page, { ...user })` spread 우회로도 hidden 값이 안 샌다(§4.2) |
|
|
586
630
|
| 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§4.4 auth) |
|
|
631
|
+
| 결정 293 | 라우트 타깃 형식 불량(`#` 누락·빈 컨트롤러/액션) = 부팅 throw — 조용한 라우트 증발 금지(미구현 액션 스킵은 정상 경로 유지) |
|
|
632
|
+
| 결정 294 | 단일 문자열 키 접근(`this.params('id')`) 표면 없음 — 애드혹 폼으로 받는다 · 애드혹 폼 중복 키는 배열 통과(last-wins 는 스키마 폼만) · JSON 액션 예약 키(redirect/json/page+props) 문서화(§3·§4.3·함정) |
|
|
633
|
+
| 결정 295 | 세션 쿠키 `secure`/`sameSite` 를 app.config session 에서 조정 · `sameSite:'none'`+secure 미충족 = 부팅 에러(§6) |
|
|
634
|
+
| 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw(조용한 no-op 금지 · §6) |
|
|
635
|
+
| 결정 297 | 초기 HTML 문서에도 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — CDN/공유 캐시가 사용자별 data-page(csrf·currentUser)를 캐시하지 못하게 |
|
|
636
|
+
| 결정 298 | render props 순환 참조 = 스택 오버플로 대신 수리 안내 에러(같은 객체의 형제 중복(DAG)은 정상) |
|
|
637
|
+
| 결정 337 | JWT 하드닝 — secret 32자 하한(부팅) · 운영 dev 폴백 거부 · 검증 alg HS256 고정 · stateless 폐기 불가 한계 명기(§6) |
|
|
638
|
+
| 결정 338 | `gaon g auth --jwt --app <api>` — API 앱 토큰 스캐폴드(발급/재발급/내 정보 · 페이지·가입 없음 · `<APP>_JWT_SECRET` 시드 · §6) |
|
|
639
|
+
| 결정 339 | 앱 스코프 보안 override — `app.config` `security: { cors, rateLimit }`(생략 = 전역 상속 · rate limit 버킷은 앱 단위 · `agents/security.md` §1) |
|
|
640
|
+
| 결정 340 | doctor `render-return` — 응답 호출만 하고 return 누락 = 무신호 204 경고(함정 §알려진 함정) |
|
|
587
641
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|
|
588
642
|
|
|
589
643
|
## `@gaonjs/seal` 켠 앱
|
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
// 앱 전용 컴포저블은 api()/pageProps() 를 자유롭게 쓸 수 있다.
|
|
4
4
|
// shared/composables 는 반대 — 인자로만 받는 순수 로직(E-5 §2.2).
|
|
5
5
|
import { ref } from 'vue'
|
|
6
|
+
import { api } from 'gaonjs/vue'
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
+
/** JSON 액션 home#health 를 api() 로 두드려 서버가 살아 있는지 확인하는 예시 컴포저블. */
|
|
8
9
|
export function useApiPing() {
|
|
9
10
|
const ok = ref<boolean | null>(null)
|
|
10
11
|
const error = ref<string | null>(null)
|
|
11
12
|
|
|
12
13
|
async function ping(): Promise<void> {
|
|
13
14
|
try {
|
|
14
|
-
|
|
15
|
-
const body =
|
|
15
|
+
// 타입드 api() 클라이언트(errata E-3) — raw fetch() 는 API 앱(JWT) 전용이다.
|
|
16
|
+
const body = await api('web:home#health')
|
|
16
17
|
ok.value = body.ok === true
|
|
17
18
|
error.value = null
|
|
18
19
|
} catch (err) {
|
|
@@ -12,7 +12,7 @@ export default controller({
|
|
|
12
12
|
},
|
|
13
13
|
|
|
14
14
|
// GET /health — JSON 액션(errata E-3). 반환값이 곧 응답.
|
|
15
|
-
// 배포 후 헬스체크·60초 실측(v0.
|
|
15
|
+
// 배포 후 헬스체크·60초 실측(v0.17 §13.5 M9 완료 기준)에 쓰인다.
|
|
16
16
|
async health() {
|
|
17
17
|
return { ok: true, service: '{{PROJECT_NAME}}' }
|
|
18
18
|
},
|
package/dist/work.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type WorkEvent } from '@gaonjs/async';
|
|
1
2
|
export interface WorkCommandOptions {
|
|
2
3
|
readonly json?: boolean;
|
|
3
4
|
/** NATS 접속지. 생략 시 NATS_URL(그다음 하위호환 GAON_NATS_URL), 기본(4222). */
|
|
@@ -12,12 +13,32 @@ export interface WorkCommandOptions {
|
|
|
12
13
|
readonly ackWaitMs?: number;
|
|
13
14
|
/** graceful drain 상한(ms). 생략 시 GAON_WORKER_DRAIN_MS. */
|
|
14
15
|
readonly drainTimeoutMs?: number;
|
|
16
|
+
/** 발행 완료 아웃박스 행 보존(ms · 0=purge 비활성). 생략 시 GAON_OUTBOX_RETENTION_MS(결정 312). */
|
|
17
|
+
readonly outboxRetentionMs?: number;
|
|
18
|
+
/** 아웃박스 purge 최소 간격(ms). 생략 시 GAON_OUTBOX_PURGE_INTERVAL_MS(결정 312). */
|
|
19
|
+
readonly outboxPurgeIntervalMs?: number;
|
|
20
|
+
/** 아웃박스 릴레이 폴링 주기(ms). 생략 시 GAON_OUTBOX_RELAY_POLL_MS(결정 312). */
|
|
21
|
+
readonly relayPollMs?: number;
|
|
15
22
|
/** 시그널 등록·해제(테스트 주입). 기본 process. */
|
|
16
23
|
readonly signals?: {
|
|
17
24
|
on(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
|
|
18
25
|
off(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
|
|
19
26
|
};
|
|
20
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* 아웃박스 릴레이 튜닝을 env 에서 읽는다(결정 312 · GAON_WORKER_* 와 대칭).
|
|
30
|
+
* 이전엔 runWork() 프로그래매틱 옵션으로만 존재해 정본 운영 경로(`gaon work`)에서
|
|
31
|
+
* 보존 기간·purge 간격·폴링 주기를 조정할 방법이 없었다. env 미설정·비정상 값은
|
|
32
|
+
* undefined → runWork 기본(보존 7일 · purge 1시간 · 폴 1초 · 결정 78)이 그대로다.
|
|
33
|
+
*/
|
|
34
|
+
export declare function outboxTuningFromEnv(env?: Record<string, string | undefined>): {
|
|
35
|
+
outboxRetentionMs?: number;
|
|
36
|
+
outboxPurgeIntervalMs?: number;
|
|
37
|
+
relayPollMs?: number;
|
|
38
|
+
};
|
|
39
|
+
/** WorkEvent → 사람용 한 줄(없으면 undefined). 결정 306·308: 조용한 실패(릴레이
|
|
40
|
+
* 오류·리스너 폐기·워커 인프라 오류)가 기본(human) 모드에서 0 신호이던 갭을 닫는다. */
|
|
41
|
+
export declare function emitHuman(e: WorkEvent): string | undefined;
|
|
21
42
|
/**
|
|
22
43
|
* 워커를 띄우고 시그널까지 살려 둔다. 반환 프라미스는 graceful 종료 시 resolve.
|
|
23
44
|
*/
|
package/dist/work.js
CHANGED
|
@@ -29,7 +29,29 @@ function envInt(name) {
|
|
|
29
29
|
const n = Number(raw);
|
|
30
30
|
return Number.isFinite(n) ? n : undefined;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* 아웃박스 릴레이 튜닝을 env 에서 읽는다(결정 312 · GAON_WORKER_* 와 대칭).
|
|
34
|
+
* 이전엔 runWork() 프로그래매틱 옵션으로만 존재해 정본 운영 경로(`gaon work`)에서
|
|
35
|
+
* 보존 기간·purge 간격·폴링 주기를 조정할 방법이 없었다. env 미설정·비정상 값은
|
|
36
|
+
* undefined → runWork 기본(보존 7일 · purge 1시간 · 폴 1초 · 결정 78)이 그대로다.
|
|
37
|
+
*/
|
|
38
|
+
export function outboxTuningFromEnv(env = process.env) {
|
|
39
|
+
const readInt = (name) => {
|
|
40
|
+
const raw = env[name];
|
|
41
|
+
if (raw == null || raw === '')
|
|
42
|
+
return undefined;
|
|
43
|
+
const n = Number(raw);
|
|
44
|
+
return Number.isFinite(n) ? n : undefined;
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
outboxRetentionMs: readInt('GAON_OUTBOX_RETENTION_MS'),
|
|
48
|
+
outboxPurgeIntervalMs: readInt('GAON_OUTBOX_PURGE_INTERVAL_MS'),
|
|
49
|
+
relayPollMs: readInt('GAON_OUTBOX_RELAY_POLL_MS'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** WorkEvent → 사람용 한 줄(없으면 undefined). 결정 306·308: 조용한 실패(릴레이
|
|
53
|
+
* 오류·리스너 폐기·워커 인프라 오류)가 기본(human) 모드에서 0 신호이던 갭을 닫는다. */
|
|
54
|
+
export function emitHuman(e) {
|
|
33
55
|
switch (e.kind) {
|
|
34
56
|
case 'ready':
|
|
35
57
|
return ` gaon work · 준비 — 잡 ${e.jobs} · 리스너 ${e.listeners} · 스케줄 ${e.scheduled}`;
|
|
@@ -38,13 +60,30 @@ function emitHuman(e) {
|
|
|
38
60
|
return e.event.leader ? ' ▶ 스케줄러 리더 — 틱 발행 시작' : ' · 스케줄러 대기(standby)';
|
|
39
61
|
if (e.event.kind === 'fired')
|
|
40
62
|
return ` ⏰ 스케줄 발행 — ${e.event.label}`;
|
|
63
|
+
if (e.event.kind === 'error')
|
|
64
|
+
return ` ⚠ 스케줄러 오류 — ${e.event.error}`;
|
|
41
65
|
return undefined;
|
|
42
66
|
case 'worker':
|
|
43
67
|
if (e.event.kind === 'dead')
|
|
44
68
|
return ` ✗ 잡 DLQ — ${e.event.job} (${e.event.error})`;
|
|
69
|
+
// 결정 308: 재시도·인프라 오류(재적재/DLQ 발행 실패 nak · 결정 258)도 신호한다.
|
|
70
|
+
if (e.event.kind === 'retrying')
|
|
71
|
+
return ` ↻ 잡 재시도 — ${e.event.job} (시도 ${e.event.attempt} · ${e.event.delayMs}ms 뒤)`;
|
|
72
|
+
if (e.event.kind === 'error')
|
|
73
|
+
return ` ⚠ 워커 오류 — ${e.event.error}`;
|
|
74
|
+
return undefined;
|
|
75
|
+
case 'listener':
|
|
76
|
+
// 결정 308: maxDeliver 소진 = 이벤트 영구 폐기(DLQ 없음 · agents/async.md §3) — 반드시 신호.
|
|
77
|
+
if (e.event.kind === 'dropped')
|
|
78
|
+
return ` ✗ 이벤트 폐기 — ${e.event.listener} ← ${e.event.event} (재전달 소진: ${e.event.error})`;
|
|
79
|
+
if (e.event.kind === 'error')
|
|
80
|
+
return ` ⚠ 리스너 오류 — ${e.event.error}`;
|
|
45
81
|
return undefined;
|
|
46
82
|
case 'relay':
|
|
47
83
|
return ` ↪ 아웃박스 릴레이 — ${e.count}건 발행`;
|
|
84
|
+
case 'relay-error':
|
|
85
|
+
// 결정 306: 아웃박스 발행 실패(행 격리·재시도 유지)를 조용히 삼키지 않는다.
|
|
86
|
+
return ` ⚠ 아웃박스 릴레이 오류 — ${e.error}`;
|
|
48
87
|
case 'purge':
|
|
49
88
|
return ` 🧹 아웃박스 정리 — ${e.count}건 삭제(보존 기간 초과)`;
|
|
50
89
|
default:
|
|
@@ -99,6 +138,7 @@ export async function runWorkCommand(opts = {}) {
|
|
|
99
138
|
registerConnection('main', createDb(cfg), cfg.adapter);
|
|
100
139
|
}
|
|
101
140
|
const db = hasConnection('main') ? getConnection('main') : undefined;
|
|
141
|
+
const outboxEnv = outboxTuningFromEnv();
|
|
102
142
|
const domain = await loadDomain(root);
|
|
103
143
|
// 로드된 도메인 자산 요약(파일=등록 관측용). 잡·리스너·메일 수를 노출한다.
|
|
104
144
|
if (json) {
|
|
@@ -120,6 +160,10 @@ export async function runWorkCommand(opts = {}) {
|
|
|
120
160
|
concurrency: opts.concurrency ?? envInt('GAON_WORKER_CONCURRENCY'),
|
|
121
161
|
ackWaitMs: opts.ackWaitMs ?? envInt('GAON_WORKER_ACK_WAIT_MS'),
|
|
122
162
|
drainTimeoutMs: opts.drainTimeoutMs ?? envInt('GAON_WORKER_DRAIN_MS'),
|
|
163
|
+
// 아웃박스 릴레이 튜닝: 옵션 > GAON_OUTBOX_* env > runWork 기본(결정 78·312).
|
|
164
|
+
outboxRetentionMs: opts.outboxRetentionMs ?? outboxEnv.outboxRetentionMs,
|
|
165
|
+
outboxPurgeIntervalMs: opts.outboxPurgeIntervalMs ?? outboxEnv.outboxPurgeIntervalMs,
|
|
166
|
+
relayPollMs: opts.relayPollMs ?? outboxEnv.relayPollMs,
|
|
123
167
|
onEvent: emit,
|
|
124
168
|
});
|
|
125
169
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://gaonjs.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://git.nyx-zone.com/gaon/framework.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
8
13
|
"engines": {
|
|
9
14
|
"node": ">=22"
|
|
10
15
|
},
|
|
@@ -27,15 +32,15 @@
|
|
|
27
32
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
33
|
"typescript": "^5.9.0",
|
|
29
34
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/async": "0.
|
|
31
|
-
"@gaonjs/config": "0.
|
|
32
|
-
"@gaonjs/data": "0.21.0",
|
|
35
|
+
"@gaonjs/async": "0.17.0",
|
|
36
|
+
"@gaonjs/config": "0.22.0",
|
|
33
37
|
"@gaonjs/core": "0.2.4",
|
|
38
|
+
"@gaonjs/data": "0.24.0",
|
|
34
39
|
"@gaonjs/i18n": "0.2.4",
|
|
35
|
-
"@gaonjs/
|
|
36
|
-
"@gaonjs/
|
|
40
|
+
"@gaonjs/mail": "0.4.0",
|
|
41
|
+
"@gaonjs/web": "0.27.0"
|
|
37
42
|
},
|
|
38
43
|
"scripts": {
|
|
39
|
-
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|
|
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})\""
|
|
40
45
|
}
|
|
41
46
|
}
|