@gaonjs/cli 0.33.0 → 0.35.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/dist/commands/check.d.ts +10 -1
- package/dist/commands/check.js +10 -6
- package/dist/commands/dev.js +2 -0
- package/dist/commands/gen.js +4 -2
- package/dist/db/projectData.d.ts +14 -0
- package/dist/db/projectData.js +51 -0
- package/dist/db.js +38 -2
- package/dist/dev.d.ts +11 -1
- package/dist/dev.js +29 -1
- package/dist/generate.d.ts +24 -2
- package/dist/generate.js +112 -34
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -5
- package/dist/messages-gen.d.ts +6 -0
- package/dist/messages-gen.js +24 -0
- package/dist/templates/auth/Login.vue.tpl +1 -4
- package/dist/templates/auth/dashboard.secure.controller.ts.tpl +14 -0
- package/dist/templates/project/.env.example.tpl +6 -0
- package/dist/templates/project/AGENTS.md.tpl +3 -1
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/async.md.tpl +37 -0
- package/dist/templates/project/agents/data.md.tpl +70 -2
- package/dist/templates/project/agents/frontend.md.tpl +3 -1
- package/dist/templates/project/agents/i18n.md.tpl +107 -0
- package/dist/templates/project/agents/mail.md.tpl +92 -0
- package/dist/templates/project/agents/realtime.md.tpl +8 -0
- package/dist/templates/project/agents/security.md.tpl +56 -10
- package/dist/templates/project/agents/testing.md.tpl +43 -0
- package/dist/templates/project/agents/web.md.tpl +54 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +20 -1
- package/dist/templates/project/gaon.config.ts.tpl +14 -0
- package/package.json +7 -6
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# agents/i18n.md — 다국어 (`t()` · 요청별 로케일 · 메시지 키 타입)
|
|
2
|
+
|
|
3
|
+
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
|
+
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·표·예시의 정본은 이 파일이다.
|
|
5
|
+
> 대상 패키지: `@gaonjs/i18n` (파사드 import 는 `gaonjs/i18n`).
|
|
6
|
+
|
|
7
|
+
## 정본 규칙
|
|
8
|
+
|
|
9
|
+
### 1. 카탈로그와 `t()`
|
|
10
|
+
|
|
11
|
+
번역 문자열은 프로젝트 루트 `locales/<로케일>.json` 에 둔다(중첩 JSON = 점 표기
|
|
12
|
+
키). `t('key')` 로 현재 요청 로케일의 문자열을 얻는다 — 어디서든(컨트롤러·서비스·
|
|
13
|
+
잡·메일) 쓸 수 있다.
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
// locales/ko.json
|
|
17
|
+
{ "greeting": "안녕하세요, {{name}}님", "nav": { "home": "홈" } }
|
|
18
|
+
// locales/en.json
|
|
19
|
+
{ "greeting": "Hello, {{name}}", "nav": { "home": "Home" } }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { t } from 'gaonjs/i18n'
|
|
24
|
+
t('greeting', { name: '가온' }) // 요청 로케일이 ko 면 "안녕하세요, 가온님"
|
|
25
|
+
t('nav.home') // 중첩은 점 표기
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| 표면 | 시그니처 | 비고 |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| 번역 | `t(key, params?)` | 키는 카탈로그에서 타입 검사(아래 §4) · params 는 `{{name}}` 보간 |
|
|
31
|
+
| 현재 언어 | `currentLanguage(): string` | 요청 로케일 |
|
|
32
|
+
| 지원 언어 | `languages(): string[]` | 설정된 supportedLngs |
|
|
33
|
+
| 고정 번역 | `runWithLanguage(lng, fn)` | fn 안의 t() 가 그 언어(메일·비요청 경로 · §mail) |
|
|
34
|
+
|
|
35
|
+
### 2. 요청별 로케일 자동 협상 (결정 159)
|
|
36
|
+
|
|
37
|
+
`gaon.config.ts` 에 `i18n` 설정이 있으면 `gaon serve`/`gaon dev` 가 **매 요청**
|
|
38
|
+
로케일을 협상해 `t()` 가 그 언어로 번역한다 — 앱이 손으로 배선할 필요가 없다
|
|
39
|
+
(자동 · opt-in 아님). 우선순위 기본값 **세션 > 쿠키 > 헤더**(명시 선택이 브라우저
|
|
40
|
+
기본을 이긴다). 지원 안 하는 로케일은 `fallbackLng`.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// gaon.config.ts
|
|
44
|
+
export default defineConfig({
|
|
45
|
+
i18n: {
|
|
46
|
+
fallbackLng: 'ko',
|
|
47
|
+
supportedLngs: ['ko', 'en', 'ja'], // 생략 시 locales/ 폴더 하위 언어들
|
|
48
|
+
// dir: 'locales', // 생략 시 'locales'
|
|
49
|
+
// detect: { cookieName: 'gaon_locale', priority: ['session', 'cookie', 'header'] }, // 기본값
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 3. 로케일 전환 — `this.setLocale()` (결정 159)
|
|
55
|
+
|
|
56
|
+
사용자가 언어를 바꾸면 `this.setLocale(lng)` 로 저장한다 — `gaon_locale` 쿠키
|
|
57
|
+
(세션이 있으면 세션에도)에 심어 **다음 요청부터 유지**된다. 쿠키 기반이라 세션
|
|
58
|
+
없는 앱(랜딩·API)에서도·앱 간에도 유지된다.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// 컨트롤러 — 언어 전환 라우트
|
|
62
|
+
async setLocale() {
|
|
63
|
+
this.setLocale((this.request.params as { lng: string }).lng)
|
|
64
|
+
return this.redirect(this.request.headers.referer ?? '/')
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 4. 메시지 키 타입 — `.gaon/messages.d.ts` (결정 158)
|
|
69
|
+
|
|
70
|
+
`gaon gen`/`gaon dev`/`gaon check` 가 `locales/` 카탈로그를 읽어
|
|
71
|
+
`.gaon/messages.d.ts`(키 유니온)를 생성한다(routes·tables 와 같은 `.gaon`
|
|
72
|
+
파이프라인의 3번째 축). 그러면 `t('없는키')` 가 **컴파일 에러**로 잡힌다 —
|
|
73
|
+
카탈로그에 없는 키·오타가 `gaon check` 에서 걸린다(카탈로그가 없으면 키는
|
|
74
|
+
`string` 폴백). 이 파일은 자동 생성이니 직접 수정하지 않는다.
|
|
75
|
+
|
|
76
|
+
## 정본 예시
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// domain/services/greet.ts — 서비스·잡에서도 t() 는 요청 로케일을 쓴다.
|
|
80
|
+
import { t } from 'gaonjs/i18n'
|
|
81
|
+
export function greetLine(name: string): string {
|
|
82
|
+
return t('greeting', { name })
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`t()` 는 요청 컨텍스트(ALS)의 로케일을 자동으로 따라간다 — 로케일을 인자로
|
|
87
|
+
넘기고 다니지 않는다. 요청 밖(크론·스크립트)이나 특정 로케일로 강제하려면
|
|
88
|
+
`runWithLanguage(lng, () => t('key'))`.
|
|
89
|
+
|
|
90
|
+
## 알려진 함정
|
|
91
|
+
|
|
92
|
+
- **`i18n` 설정이 없으면 `t()` 는 항상 fallback** — 요청별 로케일 협상은
|
|
93
|
+
`gaon.config.ts` 에 `i18n` 이 있어야 배선된다(결정 159). 설정만 하면 자동.
|
|
94
|
+
- **키를 손으로 `string` 으로 넓히지 말 것** — `.gaon/messages.d.ts`(결정 158)가
|
|
95
|
+
키를 타입으로 좁혀 준다. `gaon check` 가 없는 키를 잡는다.
|
|
96
|
+
- **로케일을 함수 인자로 실어 나르지 말 것** — `t()` 는 ALS 로 요청 로케일을 안다.
|
|
97
|
+
전환은 `this.setLocale`, 특정 로케일 강제는 `runWithLanguage`.
|
|
98
|
+
- **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })` 로
|
|
99
|
+
명시한다(`agents/mail.md` · 결정 160).
|
|
100
|
+
|
|
101
|
+
## 관련 결정 번호
|
|
102
|
+
|
|
103
|
+
| 결정 | 요지 |
|
|
104
|
+
|---|---|
|
|
105
|
+
| §7 (v0.15) | i18n 배터리 · locales/ 카탈로그 · t() |
|
|
106
|
+
| 결정 158 (13차 W2) | `.gaon/messages.d.ts` 키 타입 브리지 — 없는 키 컴파일 에러 |
|
|
107
|
+
| 결정 159 (13차 W1) | 요청별 로케일 자동 협상(wireGaon onRequest) · `this.setLocale` · detect 설정 |
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# agents/mail.md — 메일 (`mail()` · `deliver` · 로케일 · MailPit)
|
|
2
|
+
|
|
3
|
+
> 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
|
|
4
|
+
> 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·표·예시의 정본은 이 파일이다.
|
|
5
|
+
> 대상 패키지: `@gaonjs/mail` (파사드 import 는 `gaonjs/mail`).
|
|
6
|
+
|
|
7
|
+
## 정본 규칙
|
|
8
|
+
|
|
9
|
+
### 1. `domain/mails/` + `mail()`
|
|
10
|
+
|
|
11
|
+
메일은 `domain/mails/<이름>.ts` 에 `mail()` 로 정의한다(모델·잡과 같은 함수/객체
|
|
12
|
+
스타일 · 데코레이터 금지). 파일을 두면 등록이고 파일명이 곧 이름이다. build 함수는
|
|
13
|
+
데이터를 받아 메시지(`to`·`subject`·`html`/`text`·`from?`)를 만든다.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// domain/mails/welcome.ts
|
|
17
|
+
import { mail } from 'gaonjs/mail'
|
|
18
|
+
import { t } from 'gaonjs/i18n'
|
|
19
|
+
|
|
20
|
+
export const WelcomeMail = mail<{ name: string; email: string }>((u) => ({
|
|
21
|
+
to: u.email,
|
|
22
|
+
subject: t('mail.welcome.subject', { name: u.name }),
|
|
23
|
+
html: `<h1>${t('mail.welcome.body')}</h1>`,
|
|
24
|
+
}))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| 표면 | 시그니처 | 비고 |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| 정의 | `mail<T>((data) => MailMessage)` | 파일명 = 이름 |
|
|
30
|
+
| 발송 | `def.deliver(data, { locale?, to? })` | 설정된 SMTP 로 보냄 |
|
|
31
|
+
| 미리보기 | `def.render(data, { locale? })` | 발송 없이 메시지만(테스트·미리보기) |
|
|
32
|
+
|
|
33
|
+
### 2. 로케일 메일 — `deliver(data, { locale })` (결정 160)
|
|
34
|
+
|
|
35
|
+
다국어 메일은 발송 시 **수신자 로케일**을 명시한다 — `deliver(data, { locale })`
|
|
36
|
+
가 그 언어 컨텍스트로 렌더해 build 본문의 `t()` 가 그 로케일로 번역된다. 수신자
|
|
37
|
+
로케일은 앱이 `recipient.locale` 로 넘긴다(프레임웍이 모델 구조를 알지 않는다).
|
|
38
|
+
`locale` 생략 시 현재 요청 로케일(없으면 fallback).
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
await WelcomeMail.deliver({ name: user.name, email: user.email }, { locale: user.locale })
|
|
42
|
+
// to 옵션으로 수신자를 데이터 밖에서 덮어쓸 수도 있다:
|
|
43
|
+
await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 3. 발송 경로 — 잡/`afterCommit` 으로 (요청 경로 아님)
|
|
47
|
+
|
|
48
|
+
메일 발송은 느린 외부 I/O 다 — **요청 액션 안에서 직접 `deliver` 하지 않는다**
|
|
49
|
+
(비동기 배치 판단표 · `agents/async.md`). 커밋 뒤 발송이면 서비스 `afterCommit`,
|
|
50
|
+
그 외에는 잡(`domain/jobs/`)으로 빼서 `.later()` 로 발행한다. `gaon doctor` 의
|
|
51
|
+
`async-offload` 가 컨트롤러의 메일 SDK 직접 import 를 경고한다.
|
|
52
|
+
|
|
53
|
+
### 4. 개발 = MailPit 싱크
|
|
54
|
+
|
|
55
|
+
`gaon dev` 의 compose 가 MailPit 을 띄운다(SMTP 캡처 + 웹 UI `:8025`) — 개발 중
|
|
56
|
+
보낸 메일은 실제로 나가지 않고 MailPit 수신함에서 확인한다. 스캐폴드 `gaon.config.ts`
|
|
57
|
+
·`.env.example` 에 mail 블록이 이미 있어(결정 160) `cp .env.example .env` 후 바로 돈다.
|
|
58
|
+
운영은 `SMTP_HOST`·자격증명·`SMTP_SECURE=true` 로 교체(같은 코드).
|
|
59
|
+
|
|
60
|
+
## 정본 예시
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// domain/jobs/sendWelcome.ts — 발송은 잡으로(요청 경로 보호 · async.md).
|
|
64
|
+
import { job } from 'gaonjs/async'
|
|
65
|
+
import { WelcomeMail } from '../mails/welcome.js'
|
|
66
|
+
import { User } from '../models/User.js'
|
|
67
|
+
|
|
68
|
+
export const SendWelcome = job(async (userId: bigint) => {
|
|
69
|
+
const user = await User.where('id', '=', userId).first()
|
|
70
|
+
if (user) await WelcomeMail.deliver(user, { locale: user.locale })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// 서비스에서: afterCommit(() => SendWelcome.later(user.id))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 알려진 함정
|
|
77
|
+
|
|
78
|
+
- **요청 액션에서 직접 `deliver` 금지** — 느린 SMTP 가 응답을 세운다. 잡/`afterCommit`
|
|
79
|
+
으로 뺀다(`agents/async.md` 판단표 · doctor `async-offload`).
|
|
80
|
+
- **메일 로케일 ≠ 요청 로케일** — 메일은 요청과 다른 컨텍스트(잡)에서 나갈 수 있다.
|
|
81
|
+
`deliver(data, { locale: recipient.locale })` 로 **명시**한다(결정 160).
|
|
82
|
+
- **from 이 없으면 발송 에러** — 메시지에 `from` 을 주거나 `configureMailer({ defaultFrom })`
|
|
83
|
+
(스캐폴드 config 의 `MAIL_FROM`)를 설정한다.
|
|
84
|
+
- **레이아웃 상속은 v1 에 없다** — 공통 레이아웃은 v1.1 백로그(결정 160). v1 은 각
|
|
85
|
+
메일이 자기 html 을 낸다.
|
|
86
|
+
|
|
87
|
+
## 관련 결정 번호
|
|
88
|
+
|
|
89
|
+
| 결정 | 요지 |
|
|
90
|
+
|---|---|
|
|
91
|
+
| §7 (v0.15) | 메일 배터리 · `domain/mails/` · MailPit dev sink |
|
|
92
|
+
| 결정 160 (13차 W3) | `deliver(data, { locale, to })` 로케일 인지 · 스캐폴드 mail 블록 · 레이아웃 v1.1 백로그 |
|
|
@@ -134,6 +134,13 @@ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{
|
|
|
134
134
|
을 손으로 짜지 말 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
|
|
135
135
|
자동 인증, JWT 앱은 `params: { access_token }`.
|
|
136
136
|
|
|
137
|
+
**앱 프리픽스는 자동이다(결정 154).** 서버는 채널 WS 를 `<앱 프리픽스>/gaon/ws/:channel`
|
|
138
|
+
에 등록하고, `useChannel` 은 그 앱 번들의 `import.meta.env.BASE_URL`(= vite base = 앱
|
|
139
|
+
프리픽스 · 에셋 base 와 단일 소스 · 결정 146)을 읽어 같은 프리픽스로 붙는다: web('/') →
|
|
140
|
+
`/gaon/ws/<name>`, admin('/admin/') → `/admin/gaon/ws/<name>`. **서브앱도 `opts.path` 를
|
|
141
|
+
손으로 넘길 필요가 없다** — `opts.path` 는 표준 vite base 를 안 쓰는 특수 배포용 **탈출구**
|
|
142
|
+
로만 남는다(명시하면 그대로 쓴다). 프리픽스를 손으로 넣던 옛 관례는 폐기됐다.
|
|
143
|
+
|
|
137
144
|
```ts
|
|
138
145
|
// apps/web/composables/useRoom.ts — 컴포저블에 래핑(agents/frontend.md §3.2)
|
|
139
146
|
import { useChannel } from 'gaonjs/vue'
|
|
@@ -247,6 +254,7 @@ export default channel({
|
|
|
247
254
|
| E-2 | 웹서버 ↔ 허브 = TCP 지속 연결 · NATS = broadcast 전용 |
|
|
248
255
|
| §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
|
|
249
256
|
| 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
|
|
257
|
+
| 결정 154 | `useChannel` 앱 프리픽스 자동 주입 — `import.meta.env.BASE_URL`(vite base·에셋 base 단일 소스) 로 `<프리픽스>/gaon/ws/<name>` · `opts.path` 는 탈출구 · 프리픽스 앱 실시간 무한 재연결 제거(§4) |
|
|
250
258
|
|
|
251
259
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
252
260
|
|
|
@@ -51,24 +51,67 @@
|
|
|
51
51
|
자동으로 붙이고, 세션 secret 을 **앱별 env** `<APP>_SESSION_SECRET`(예:
|
|
52
52
|
`ADMIN_SESSION_SECRET`)로 분리 배선한다(결정 141 · 앱별 세션 완전 분리).
|
|
53
53
|
운영 배포 시 그 env 를 web 과 **다르게** 설정할 것.
|
|
54
|
-
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
- **비-web 앱은 시큐어 기본이다** (결정 155): `gaon g auth --app admin` 은
|
|
55
|
+
**공개 회원가입(registration)을 깔지 않는다** — 관리 앱에 공개 가입이 열리고
|
|
56
|
+
로그인한 일반 고객이 관리 화면을 보던 위험 기본을 구조적으로 막는다. 대신 보호
|
|
57
|
+
라우트에 **역할 게이트**(`this.requireAuth()` + `this.authorize(user.role === 'admin')`
|
|
58
|
+
· 결정 145)를 예시로 깔고, `domain/schema/users.ts` 에 `role` 컬럼을 두라고 안내한다.
|
|
59
|
+
관리자는 직접 만들거나 승격한다(공개 가입 라우트 없음). web 앱은 현행대로 공개 가입 O.
|
|
60
|
+
공개 비-web 앱이 필요하면 `--public` 로 공개 가입을 opt-in 한다.
|
|
61
|
+
- CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF 강제.
|
|
62
|
+
**토큰은 `<meta>` 태그가 아니라 data-page 공유 prop 으로 온다 (결정 116).**
|
|
63
|
+
프레임웍 Inertia 셸은 `<meta name="csrf-token">` 을 **넣지 않는다** — csrf 는
|
|
64
|
+
모든 렌더에 자동 주입되는 공유 prop 이라 페이지에서 `useShared().csrf` 로 읽는다
|
|
65
|
+
(`packages/vue/src/shared.ts`). 폼은 그 값을 실어 보낸다 — `useForm({ ..., _csrf:
|
|
66
|
+
shared.csrf })` 또는 HTML 폼이 못 보내는 메서드는 헤더로: `router.delete(url, {
|
|
67
|
+
headers: { 'x-csrf-token': shared.csrf } })` (스캐폴드 Login/Signup/Dashboard 가
|
|
68
|
+
이 관례를 그대로 깐다).
|
|
69
|
+
- `api()` 클라이언트도 **같은 data-page csrf 를 자동으로 붙인다 (결정 166).** 세션
|
|
70
|
+
앱에서 상태 변경 JSON 액션(`api('web:posts#tagAdd', ...)` 등)을 불러도 손수 토큰을
|
|
71
|
+
넘길 필요가 없다 — `api()` 가 data-page 의 `props.csrf`(`useShared().csrf` 와 같은
|
|
72
|
+
단일 출처)를 읽어 `X-CSRF-Token` 에 실어 준다(`packages/vue/src/api.ts`). data-page
|
|
73
|
+
가 없거나(비-Inertia) 봉인(seal)이면 레거시 `<meta name="csrf-token">` 로 폴백한다.
|
|
74
|
+
JWT/API 앱은 토큰 인증이라 CSRF 대상이 아니다.
|
|
57
75
|
- **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
|
|
58
76
|
세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
|
|
59
77
|
`app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
|
|
60
78
|
한다 — 폼(POST)을 추가하는 순간 CSRF 가 이미 켜져 있다. 앱에 비-GET
|
|
61
79
|
라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
|
|
62
80
|
`csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
|
|
81
|
+
- **CSRF/세션 실패는 코어가 Inertia-네이티브로 마감한다 (결정 165).** 세션 만료·
|
|
82
|
+
secret 로테이션·장시간 탭으로 CSRF 가 실패하면, Inertia 요청은 raw JSON 403 이 아니라
|
|
83
|
+
**409 + `X-Inertia-Location` 풀 리로드**(새 세션 쿠키+새 토큰) + `flash.error` 안내로
|
|
84
|
+
마감된다 — 앱이 안 깨지고 사용자는 재시도로 성공한다. 415(지원 안 되는 Content-Type)도
|
|
85
|
+
같은 핸들러가 Inertia-네이티브 에러+수리 안내로 마감한다. 비-Inertia(API/JWT)는 종전
|
|
86
|
+
JSON 유지(회귀 없음). 상세는 `agents/web.md` §4.1. 앱은 아무것도 안 한다.
|
|
63
87
|
- JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
|
|
64
|
-
-
|
|
65
|
-
- `this.requireAuth()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
- **인증·인가는 3층이다** (결정 145 · 149):
|
|
89
|
+
- **① 인증 `this.requireAuth()`** = **로그인 여부** — 비로그인이면 401(세션 앱은
|
|
90
|
+
로그인 페이지 리다이렉트).
|
|
91
|
+
- **② 저수준 인가 `this.authorize(condition)`** = **권한 여부** — 조건이 거짓이면
|
|
92
|
+
**403**. 존재 자체를 숨겨야 하면 `this.authorize(condition, { notFound: true })` → 404.
|
|
93
|
+
조건은 호출자가 계산한다(예: `this.authorize(this.currentUser?.role === 'admin')`).
|
|
94
|
+
일회성 규칙·탈출구다.
|
|
95
|
+
- **③ 정책 객체 `policy()` + `this.can`** (결정 149) — **재사용할 인가 규칙**을 리소스별
|
|
96
|
+
"액션 → 조건 함수"로 묶는다. 가드는 저수준 authorize 로 수렴한다:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// domain/policies/post.ts
|
|
100
|
+
export const PostPolicy = policy({
|
|
101
|
+
update: (user, post: PostRec) => post.authorId === user.id,
|
|
102
|
+
destroy: (user, post: PostRec) => user.role === 'admin' || post.authorId === user.id,
|
|
103
|
+
})
|
|
104
|
+
// 컨트롤러
|
|
105
|
+
this.authorize(this.can(PostPolicy, 'update', post)) // 거부 → 403
|
|
106
|
+
if (this.can(PostPolicy, 'destroy', post)) { /* 템플릿·분기에도 */ }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
정책은 **값 객체를 그대로 넘긴다**(문자열 레지스트리 아님 · 액션·리소스 타입 검사).
|
|
110
|
+
`this.can` 은 순수 boolean(비로그인 = 거부). 저수준 `authorize(cond)` 는 그대로 살아
|
|
111
|
+
있다 — 정책은 그 위 편의층이다.
|
|
70
112
|
- 실시간 채널의 `authorize` 는 **구독 인가 전용**(§realtime) — HTTP 인가는 `this.authorize`.
|
|
71
|
-
- 손으로 403 을 throw 하거나 인가를 `notFound()` 로 우회하지 말 것 — `this.authorize`
|
|
113
|
+
- 손으로 403 을 throw 하거나 인가를 `notFound()` 로 우회하지 말 것 — `this.authorize`
|
|
114
|
+
(재사용은 `policy`)가 The One Way.
|
|
72
115
|
|
|
73
116
|
### 3. 시크릿
|
|
74
117
|
|
|
@@ -179,3 +222,6 @@ const rows = await Post.query()
|
|
|
179
222
|
| 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
|
|
180
223
|
| 결정 120 | 클라이언트 IP 신뢰 = `web.clientIp` direct/proxy/header · 헤더는 신뢰 홉 전제에서만 · IP 는 약한 신호(인가 금지) · `this.request.ip` 단일 산출(§6 · `agents/web.md` §4.4) |
|
|
181
224
|
| 결정 122 | hidden 계약은 관계(`include`/지연) 행에도 적용 — render props 로 나가는 모든 값은 `serializeProps` 통과 후 hidden 컬럼명 부재 |
|
|
225
|
+
| 결정 145 | 인가 프리미티브 `this.authorize(cond)` — 거짓 → 403(존재 은닉 시 404) · 인증(401)과 별개 축 · 저수준 탈출구 |
|
|
226
|
+
| 결정 149 | 인가 정책 객체 `policy()` + `this.can` — 재사용 규칙을 리소스별 액션→조건으로 묶음 · 값 객체(레지스트리 아님) · 가드는 `authorize(can(...))` 로 수렴 · authorize(cond) 무회귀(§2) |
|
|
227
|
+
| 결정 155 | `gaon g auth --app <비-web>` 시큐어 기본 — 공개 회원가입 미생성 + 역할 게이트(authorize) 예시 · web=공개가입 · `--public` opt-in(§2) |
|
|
@@ -87,6 +87,43 @@ describe('SendWelcomeMail (실 NATS JetStream)', () => {
|
|
|
87
87
|
- `configureJobs` 는 헬퍼가 대신 해 준다 — 테스트가 부팅 코드를 흉내낼
|
|
88
88
|
필요가 없다.
|
|
89
89
|
|
|
90
|
+
### 4.1 이벤트/리스너 헬퍼 — `expectEventProcessed`
|
|
91
|
+
|
|
92
|
+
이벤트 "emit → 리스너 실 처리" 는 잡의 대응 헬퍼 `expectEventProcessed`
|
|
93
|
+
한 호출로 확증한다 — 임시로 등록 리스너를 실 스트림에 붙여 이 이벤트가
|
|
94
|
+
**리스너 핸들러까지 실행**되는 것을 기다리고 정리한다(`expectJobProcessed`
|
|
95
|
+
대칭). ⚠️ 검증 대상 리스너(`domain/listeners/*.ts`)가 import 되어 레지스트리에
|
|
96
|
+
등록돼 있어야 한다(파일=등록).
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// test/integration/postPublished.integration.test.ts
|
|
100
|
+
import { describe, it } from 'vitest'
|
|
101
|
+
import { connectNats, expectEventProcessed } from 'gaonjs/testing'
|
|
102
|
+
import { PostPublished } from '../../domain/events/postPublished.js'
|
|
103
|
+
import '../../domain/listeners/notifyFollowers.js' // 파일=등록 — import 로 리스너 등록
|
|
104
|
+
|
|
105
|
+
describe('PostPublished (실 NATS JetStream)', () => {
|
|
106
|
+
it('emit 하면 리스너가 처리한다', async () => {
|
|
107
|
+
const nats = await connectNats()
|
|
108
|
+
try {
|
|
109
|
+
await expectEventProcessed(PostPublished, () => PostPublished.emit({ postId: 1n }), { nats })
|
|
110
|
+
} finally {
|
|
111
|
+
await nats.close()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **시그니처** — `expectEventProcessed(event, trigger, { nats, timeoutMs?, listenerId?, maxDeliver? })`.
|
|
118
|
+
`trigger` 는 emit 을 일으키는 임의 함수 — 이벤트 직접 `emit` 도, 컨트롤러·서비스
|
|
119
|
+
경유도 된다. 여러 리스너가 붙었을 때 `listenerId`(= `domain/listeners/` 파일명 ·
|
|
120
|
+
`on(..., { id })`)로 특정 리스너만 좁힌다.
|
|
121
|
+
- 리스너가 재전달을 소진하고 드롭되거나 `timeoutMs`(기본 10초) 안에 처리되지
|
|
122
|
+
않으면 수리 안내(§7.5.3)와 함께 실패한다. 실패 경로를 백오프 계단 없이 빨리
|
|
123
|
+
확증하려면 `maxDeliver: 1`(첫 실패에서 즉시 드롭).
|
|
124
|
+
- `configureEvents` 는 헬퍼가 대신 해 주고, 끝나면 하네스 배선을 복원한다
|
|
125
|
+
(`expectJobProcessed` 와 동형 · 자기 nats 를 `close()` 해도 다음 테스트 무영향).
|
|
126
|
+
|
|
90
127
|
### 5. DB 테스트 격리 — `gaon test` + `test/setup.ts` (결정 111)
|
|
91
128
|
|
|
92
129
|
DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test` 와 스캐폴드
|
|
@@ -106,6 +143,12 @@ DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test`
|
|
|
106
143
|
수동 `configureJobs` 는 필요 없다. `expectJobProcessed` 는 **자기 nats 만** 임시로
|
|
107
144
|
쓰고 끝나면 하네스 배선을 복원하므로, 그 nats 를 `close()` 해도 다음 테스트의
|
|
108
145
|
`.later()` 가 깨지지 않는다(결정 143).
|
|
146
|
+
- `connectTestDatabase` 는 **아웃박스 트랜잭션 래퍼도 배선**한다(결정 152 · `config.nats`
|
|
147
|
+
있을 때) — serve·work 의 wireGaon 과 같이. 그래서 `service()` 본문의 `emit()` 이 같은
|
|
148
|
+
트랜잭션으로 `_gaon_outbox` 에 적재되고, **서비스가 롤백되면 적재도 취소**된다(결정 144
|
|
149
|
+
의 "롤백=미발행" 보장이 테스트에서도 참). 이 배선이 없던 때는 테스트의 emit 이 즉시발행
|
|
150
|
+
경로로 새 그 보장이 **공허하게 통과**했다 — 아웃박스 롤백을 단언하는 테스트는 표준
|
|
151
|
+
하네스로 그대로 돈다(수동 `setServiceTxWrapper` 불필요).
|
|
109
152
|
|
|
110
153
|
스캐폴드가 심어 주는 `test/setup.ts`(수정 불필요):
|
|
111
154
|
|
|
@@ -39,6 +39,12 @@ export default controller({
|
|
|
39
39
|
`../../../domain/models/<Pascal>.js` 로 참조한다. `@gaonjs/*` (스코프
|
|
40
40
|
이름) 은 내부 패키지 이름 — 앱 코드에서 직접 import 하지 않는다.
|
|
41
41
|
`@inertiajs/vue3` 는 어댑터 내부 의존 — 앱에서 직접 안 쓴다.
|
|
42
|
+
- **직렬화는 자동이다** — JSON 액션 반환값·`this.json(...)`·`this.render` props 는
|
|
43
|
+
응답 경계에서 `serializeProps` 를 통과한다: `bigint`/`Date` → string, **hidden
|
|
44
|
+
컬럼 제외**(§4.2 · 결정 122), 함수 값 제거. 그래서 액션은 모델 Row 를 그대로
|
|
45
|
+
반환해도 안전하다(손 직렬화 불필요). 드물게 응답 경계 **밖**(예: 채널 broadcast
|
|
46
|
+
페이로드, 커스텀 문자열 응답)에서 같은 규칙으로 직렬화하고 싶으면 직접 부른다:
|
|
47
|
+
`import { serializeProps } from 'gaonjs/web'`.
|
|
42
48
|
- **`this.render` 인자** = `<PageFolder>/<Page>` (PascalCase 폴더 · Vue
|
|
43
49
|
파일명과 정합) — 예: `'Posts/Index'` → `apps/<app>/pages/Posts/Index.vue`.
|
|
44
50
|
- **리소스 부재 = `this.notFound()`** — 조회 결과가 없으면 404 를 손으로 만들지
|
|
@@ -233,6 +239,22 @@ async create() {
|
|
|
233
239
|
|
|
234
240
|
순수 JSON/API 앱(X-Inertia 아님·세션 없음)은 기존대로 **422 JSON** 을 받는다.
|
|
235
241
|
|
|
242
|
+
#### 세션/CSRF 실패·415 도 코어가 Inertia-네이티브로 마감한다 (결정 165)
|
|
243
|
+
|
|
244
|
+
폼 검증(결정 109)과 **대칭**으로, 컨트롤러 액션 밖(디스패처 try-catch 밖)에서 나는
|
|
245
|
+
인프라 실패도 앱이 손댈 필요 없이 코어가 마감한다 — 세션 만료·secret 로테이션·장시간
|
|
246
|
+
탭 등 운영 routine 에서 나던 raw JSON 403(앱이 깨지던 지점)을 없앤다:
|
|
247
|
+
|
|
248
|
+
- **세션/CSRF 실패**(세션 만료·CSRF 토큰 불일치) → **409 + `X-Inertia-Location` 풀
|
|
249
|
+
리로드**. Inertia 클라가 새 문서(=새 세션 쿠키 + 새 CSRF 토큰)를 받고, `flash.error`
|
|
250
|
+
로 "세션이 만료돼 다시 시도해 주세요." 안내가 뜬다(레이아웃이 `useShared().flash` 를
|
|
251
|
+
표시하면 자동 · 스캐폴드 기본 레이아웃에 배선됨). 사용자는 재시도로 성공한다.
|
|
252
|
+
- **415(지원 안 되는 Content-Type)** → **Inertia-네이티브 에러 + 수리 안내**. 지원 타입은
|
|
253
|
+
**`application/json` · `multipart/form-data`** 뿐이다 — 폼은 `useForm().post(...)`·
|
|
254
|
+
`router.<메서드>(...)`(gaonjs/vue)로 보내면 Content-Type 이 자동으로 맞는다. 수동
|
|
255
|
+
`fetch` 로 `application/x-www-form-urlencoded` 를 보내면 415 가 난다.
|
|
256
|
+
- **비-Inertia(API/JWT) 요청은 종전 JSON** 마감을 유지한다(회귀 없음).
|
|
257
|
+
|
|
236
258
|
### 4.2 공유 prop 자동 주입 — currentUser·csrf·flash (결정 116·117)
|
|
237
259
|
|
|
238
260
|
디스패처가 **모든** Inertia 렌더에 공유 prop 3종을 자동 주입한다 — 컨트롤러가
|
|
@@ -259,6 +281,37 @@ async create() {
|
|
|
259
281
|
}
|
|
260
282
|
```
|
|
261
283
|
|
|
284
|
+
#### 앱 전역 공유 키 확장 — `app.config` 의 `sharedProps` (결정 150)
|
|
285
|
+
|
|
286
|
+
코어 3종 위에 **앱이 임의 공유 키를 얹을 수 있다**(locale·theme 등). 매 컨트롤러가
|
|
287
|
+
손으로 넘기는 대신 `app.config.ts` 의 `sharedProps` 로 한 번 등록하면 그 앱의 **모든**
|
|
288
|
+
렌더에 자동 주입된다.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
// apps/web/app.config.ts
|
|
292
|
+
export default defineAppConfig({
|
|
293
|
+
sharedProps: (ctx) => ({ locale: ctx.session?.locale ?? 'en', theme: 'dark' }),
|
|
294
|
+
})
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
읽는 쪽은 타입 브리지를 **선언 병합**으로 확장한다(코어 3종은 고정 · 앱 키만 추가):
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
// shared/gaon-shared.d.ts (또는 아무 .d.ts)
|
|
301
|
+
import 'gaonjs/vue'
|
|
302
|
+
declare module 'gaonjs/vue' {
|
|
303
|
+
interface GaonSharedProps { locale: string; theme: string }
|
|
304
|
+
}
|
|
305
|
+
// 페이지에서
|
|
306
|
+
const { locale, theme } = useShared() // 타입 안전 · 반응형
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
- **코어 3종(currentUser·csrf·flash)은 예약** — `sharedProps` 가 이 이름을 반환하면
|
|
310
|
+
렌더가 throw 한다(코어 계약 보호). 다른 이름을 쓴다.
|
|
311
|
+
- 값은 렌더 경계의 `serializeProps` 를 그대로 통과한다 — **hidden 컬럼은 안 샌다**(결정 122).
|
|
312
|
+
- 변이 축(로케일·사용자 등)은 `sharedProps` 함수가 `ctx` 로 계산한다 — 프레임웍이 자동으로
|
|
313
|
+
섞지 않는다.
|
|
314
|
+
|
|
262
315
|
### 4.3 읽기 조합은 컨트롤러 인라인 조립하지 않는다 (§5.3 · 결정 114)
|
|
263
316
|
|
|
264
317
|
컨트롤러 액션에 허용되는 쿼리는 **스코프 체인 한 줄**까지다. 검색·태그 필터처럼
|
|
@@ -466,6 +519,7 @@ export default controller({
|
|
|
466
519
|
| 결정 114 | 여러 모델 조합 읽기는 이름 붙임(정적 메서드/서비스) · 컨트롤러는 스코프 체인 한 줄까지(§4.3 · `agents/data.md` §8) |
|
|
467
520
|
| 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `this.flash(k,v)` · 페이지는 `useShared()`(§4.2) |
|
|
468
521
|
| 결정 117 | render props 에 예약 공유 키 = 컴파일 에러 + 런타임 방어(자동 주입값 조용한 덮어쓰기 금지 · §4.2) |
|
|
522
|
+
| 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps → 모든 렌더 자동 주입 · 코어 3종 예약(덮으면 throw) · 선언 병합 타입 · hidden 미유출 · useShared 로 읽기(§4.2) |
|
|
469
523
|
| 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
|
|
470
524
|
| 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
|
|
471
525
|
| 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내) |
|
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
//
|
|
13
13
|
// 결정 96: 앱 내부 이동은 `Link`(선언적) — `<a href="/">` 는 전체 문서
|
|
14
14
|
// 리로드라 SPA 가 깨진다. 외부 URL 만 `<a>`(문서·GitHub).
|
|
15
|
-
import {
|
|
15
|
+
import { computed } from 'vue'
|
|
16
|
+
import { Link, useShared } from 'gaonjs/vue'
|
|
16
17
|
|
|
17
18
|
// package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
|
|
18
19
|
const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
|
|
20
|
+
|
|
21
|
+
// 공유 prop flash(결정 116) 를 앱 전역에서 한 번 표시한다 — 세션 만료 안내(결정 165 ·
|
|
22
|
+
// 코어가 심는 flash.error)나 this.flash('success', ...) 가 어느 페이지에서든 뜬다.
|
|
23
|
+
const shared = useShared()
|
|
24
|
+
const flashError = computed(() => shared.flash.error as string | undefined)
|
|
25
|
+
const flashSuccess = computed(() => shared.flash.success as string | undefined)
|
|
19
26
|
</script>
|
|
20
27
|
|
|
21
28
|
<template>
|
|
@@ -37,6 +44,18 @@ const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
|
|
|
37
44
|
</header>
|
|
38
45
|
|
|
39
46
|
<main class="w-full flex-1">
|
|
47
|
+
<div v-if="flashError || flashSuccess" class="px-6 pt-4">
|
|
48
|
+
<div
|
|
49
|
+
v-if="flashError"
|
|
50
|
+
role="alert"
|
|
51
|
+
class="mx-auto max-w-3xl rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2.5 text-sm text-destructive"
|
|
52
|
+
>{{ flashError }}</div>
|
|
53
|
+
<div
|
|
54
|
+
v-else-if="flashSuccess"
|
|
55
|
+
role="status"
|
|
56
|
+
class="mx-auto max-w-3xl rounded-md border border-primary/30 bg-primary/10 px-4 py-2.5 text-sm text-foreground"
|
|
57
|
+
>{{ flashSuccess }}</div>
|
|
58
|
+
</div>
|
|
40
59
|
<slot />
|
|
41
60
|
</main>
|
|
42
61
|
|
|
@@ -40,6 +40,20 @@ export default defineConfig({
|
|
|
40
40
|
}
|
|
41
41
|
: undefined,
|
|
42
42
|
|
|
43
|
+
// 메일(§7 · M8). SMTP — dev = MailPit(compose · 캡처 sink · UI :8025), 운영 = 실 SMTP.
|
|
44
|
+
// env 미설정이면 배선 안 함(다른 배터리 동형). 다국어 메일은 deliver(data, { locale }) —
|
|
45
|
+
// 수신자 로케일로 렌더된다(결정 160). domain/mails/*.ts 의 mail() 본문 t() 가 그 언어로.
|
|
46
|
+
mail: process.env.SMTP_HOST
|
|
47
|
+
? {
|
|
48
|
+
host: process.env.SMTP_HOST,
|
|
49
|
+
port: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 1025,
|
|
50
|
+
secure: process.env.SMTP_SECURE === 'true',
|
|
51
|
+
user: process.env.SMTP_USER,
|
|
52
|
+
pass: process.env.SMTP_PASS,
|
|
53
|
+
defaultFrom: process.env.MAIL_FROM ?? 'no-reply@{{PROJECT_NAME}}.test',
|
|
54
|
+
}
|
|
55
|
+
: undefined,
|
|
56
|
+
|
|
43
57
|
// 웹 서버 리슨 옵션. --port · env PORT 로 덮을 수 있다.
|
|
44
58
|
web: {
|
|
45
59
|
port: process.env.PORT ? Number(process.env.PORT) : 3000,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,12 +27,13 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/
|
|
30
|
+
"@gaonjs/config": "0.14.1",
|
|
31
|
+
"@gaonjs/data": "0.16.0",
|
|
32
|
+
"@gaonjs/async": "0.11.0",
|
|
33
|
+
"@gaonjs/i18n": "0.2.0",
|
|
34
|
+
"@gaonjs/mail": "0.2.0",
|
|
31
35
|
"@gaonjs/core": "0.2.1",
|
|
32
|
-
"@gaonjs/
|
|
33
|
-
"@gaonjs/data": "0.15.0",
|
|
34
|
-
"@gaonjs/web": "0.15.0",
|
|
35
|
-
"@gaonjs/mail": "0.1.3"
|
|
36
|
+
"@gaonjs/web": "0.17.0"
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|