@gaonjs/cli 0.33.0 → 0.34.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.
Files changed (33) hide show
  1. package/dist/commands/check.d.ts +10 -1
  2. package/dist/commands/check.js +10 -6
  3. package/dist/commands/dev.js +2 -0
  4. package/dist/commands/gen.js +4 -2
  5. package/dist/db/projectData.d.ts +14 -0
  6. package/dist/db/projectData.js +51 -0
  7. package/dist/db.js +38 -2
  8. package/dist/dev.d.ts +11 -1
  9. package/dist/dev.js +29 -1
  10. package/dist/generate.d.ts +11 -2
  11. package/dist/generate.js +62 -33
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +7 -5
  14. package/dist/messages-gen.d.ts +6 -0
  15. package/dist/messages-gen.js +24 -0
  16. package/dist/templates/auth/Login.vue.tpl +1 -4
  17. package/dist/templates/auth/dashboard.secure.controller.ts.tpl +14 -0
  18. package/dist/templates/project/.env.example.tpl +6 -0
  19. package/dist/templates/project/AGENTS.md.tpl +3 -1
  20. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  21. package/dist/templates/project/agents/async.md.tpl +37 -0
  22. package/dist/templates/project/agents/data.md.tpl +47 -2
  23. package/dist/templates/project/agents/frontend.md.tpl +3 -1
  24. package/dist/templates/project/agents/i18n.md.tpl +107 -0
  25. package/dist/templates/project/agents/mail.md.tpl +92 -0
  26. package/dist/templates/project/agents/realtime.md.tpl +8 -0
  27. package/dist/templates/project/agents/security.md.tpl +36 -7
  28. package/dist/templates/project/agents/testing.md.tpl +6 -0
  29. package/dist/templates/project/agents/web.md.tpl +32 -0
  30. package/dist/templates/project/gaon.config.ts.tpl +14 -0
  31. package/package.json +7 -6
  32. package/dist/templates/auth/app.ts.tpl +0 -29
  33. package/dist/templates/auth/server.ts.tpl +0 -14
@@ -0,0 +1,24 @@
1
+ // @gaonjs/cli · .gaon/messages.d.ts 생성기 (결정 158 · 13차 W2)
2
+ //
3
+ // tables.d.ts·routes.d.ts 와 같은 .gaon 파이프라인의 세 번째 축(메시지). locales/
4
+ // 카탈로그의 키를 유니온 타입으로 물성화해, t('key') 의 존재하지 않는 키를 컴파일
5
+ // 타임에 잡는다(현재는 GaonMessages 가 비어 있어 키가 string 으로 열림). 생성 파일은
6
+ // 타입만 담는다(규칙 3). @gaonjs/i18n 의 공개 API(loadLocales·renderMessagesDts)만 쓴다.
7
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
8
+ import { dirname } from 'node:path';
9
+ import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
10
+ /**
11
+ * locales/ 카탈로그에서 .gaon/messages.d.ts 를 생성한다. 카탈로그가 없거나 비어
12
+ * 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
13
+ * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
14
+ */
15
+ export function generateMessagesDts(localesDir, out) {
16
+ if (!existsSync(localesDir))
17
+ return false;
18
+ const resources = loadLocales(localesDir);
19
+ if (Object.keys(resources).length === 0)
20
+ return false;
21
+ mkdirSync(dirname(out), { recursive: true });
22
+ writeFileSync(out, renderMessagesDts(resources), 'utf8');
23
+ return true;
24
+ }
@@ -45,10 +45,7 @@ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
45
45
  </FormField>
46
46
  <Button type="submit" class="w-full" :disabled="form.processing">로그인</Button>
47
47
  </Form>
48
- <p class="mt-4 text-center text-sm text-muted-foreground">
49
- 계정이 없으신가요?
50
- <Link href="{{URL_PREFIX}}/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>
51
- </p>
48
+ {{SIGNUP_LINK}}
52
49
  </CardContent>
53
50
  </Card>
54
51
  </div>
@@ -0,0 +1,14 @@
1
+ // 보호 라우트 예시(관리 앱) — gaon g auth --app {{APP_NAME}}. 로그인 + 역할로 지킨다(§7 · 결정 145·155).
2
+ import { controller } from 'gaonjs/web'
3
+
4
+ export default controller({
5
+ // GET {{URL_PREFIX}}/dashboard — 로그인만으론 부족한 관리 화면. 역할(role)로 인가한다.
6
+ async show() {
7
+ const user = this.requireAuth() // 인증(401): 로그인 여부
8
+ // 인가(403): 로그인한 일반 사용자는 막는다. domain/schema/users.ts 에 role 컬럼을 두고
9
+ // (예: role: t.string().default('user')) 아래를 실제 역할 규칙으로 바꾸세요.
10
+ // 지금은 role !== 'admin' 이면 403 — 관리 앱에 로그인 고객이 들어오는 위험 기본을 막는다.
11
+ this.authorize((user as { role?: string }).role === 'admin')
12
+ return this.render('Dashboard', {})
13
+ },
14
+ })
@@ -18,6 +18,12 @@ STORAGE_BUCKET={{PROJECT_NAME}}
18
18
  STORAGE_ACCESS_KEY={{PROJECT_NAME}}
19
19
  STORAGE_SECRET_KEY={{PROJECT_NAME}}_secret
20
20
 
21
+ # 메일(§7 · M8) — docker-compose.yaml 의 mailpit 서비스와 정합(dev = MailPit sink · UI :8025).
22
+ # 운영은 실 SMTP 호스트·자격증명·SMTP_SECURE=true 로 교체한다.
23
+ SMTP_HOST=127.0.0.1
24
+ SMTP_PORT=1025
25
+ MAIL_FROM=no-reply@{{PROJECT_NAME}}.test
26
+
21
27
  # 세션 · 쿠키 서명 비밀 (32자 이상, 운영은 반드시 교체).
22
28
  SESSION_SECRET=change-me-to-a-32-char-random-secret!!
23
29
  COOKIE_SECRET=change-me-too-32-char-random-secret!!
@@ -27,6 +27,8 @@ v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구
27
27
  | 잡 · 이벤트 · 리스너 · 아웃박스 · 스케줄 | `agents/async.md` |
28
28
  | 채널 · 프레즌스 · 허브 | `agents/realtime.md` |
29
29
  | 파일 스토리지 (`Storage.put/url` · s3Disk · presigned · CSP 자동 배선) | `agents/storage.md` |
30
+ | 다국어 (`t()` · 카탈로그 · 요청별 로케일 · `this.setLocale` · 메시지 키 타입) | `agents/i18n.md` |
31
+ | 메일 (`mail()` · `deliver(data, { locale })` · MailPit · 발송 경로) | `agents/mail.md` |
30
32
  | 테스트 작성·실행 (실 인프라 · `expectJobProcessed`) | `agents/testing.md` |
31
33
  | 보안 기본값 · 탈출구(v-html · raw SQL) 사용 | `agents/security.md` |
32
34
  | 페이로드 봉인 (`@gaonjs/seal` · wire/문서/WS 암호화 · 선택 플러그인) | `agents/seal.md` |
@@ -191,7 +193,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
191
193
  작업마다 실행한다:
192
194
 
193
195
  ```bash
194
- gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
196
+ gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
195
197
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
196
198
  gaon doctor # 정적 검사 24종 (§2.2)
197
199
  ```
@@ -84,7 +84,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
84
84
  ## 3. 개발 검증 루프 (작업마다 실행)
85
85
 
86
86
  ```bash
87
- gaon check # .gaon 재생성 타입 검사 (CI 정합)
87
+ gaon check # .gaon 재생성 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
88
88
  gaon doctor # 정적 검사 24종 (상세 AGENTS §2.2)
89
89
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
90
90
  ```
@@ -253,6 +253,38 @@ export default schedule((s) => {
253
253
  프로세스 3종(serve·work·hub) 중 하나. SIGTERM/SIGINT 에 graceful
254
254
  drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤 종료한다.
255
255
 
256
+ ### 7. 분산 락 (`lock()`) (결정 147)
257
+
258
+ **동시 실행을 막아야 하면 `lock(key, fn)`** — 같은 `key` 에 대해 전
259
+ 인스턴스를 통틀어 동시 1개의 `fn` 만 임계구역에 들인다. **로컬 뮤텍스는
260
+ 반정본이다** — 멀티 인스턴스(워커 여러 대·serve 여러 대)에서는 프로세스마다
261
+ 따로 놀아 무의미하다(결정 88 ①). 그래서 백엔드는 Redis 다: 설정에 `redis`
262
+ 가 있으면 `gaon serve` 가 분산 락을 자동 배선한다. **`redis` 미설정 상태로
263
+ `lock()` 을 부르면 로컬 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께
264
+ throw** 한다.
265
+
266
+ ```ts
267
+ import { lock } from 'gaonjs/async'
268
+
269
+ // 일 1회 집계가 인스턴스 여러 대에서 중복 실행되지 않게.
270
+ await lock('report:daily', async () => {
271
+ await buildDailyReport()
272
+ })
273
+
274
+ // 이미 다른 인스턴스가 돌고 있으면 스킵(대기하지 않음).
275
+ await lock('sync:external', syncNow, { onBusy: 'skip' })
276
+ ```
277
+
278
+ - **기본 정책은 대기**(`onBusy:'wait'`) — 홀더가 놓을 때까지 기다렸다가
279
+ 들어간다. `acquireTimeoutMs`(기본 10s)를 넘기면 `LockTimeoutError`.
280
+ `onBusy:'skip'` 이면 즉시 포기하고 `fn` 을 실행하지 않는다(반환값
281
+ `undefined`).
282
+ - **TTL 로 데드락을 막는다**(`ttlMs` 기본 30s) — 홀더가 크래시해도 TTL
283
+ 만료 뒤 자동 해제된다. `fn` 이 TTL 보다 오래 돌면 워치독이 자동으로
284
+ 락을 연장하므로 임계구역을 뺏기지 않는다.
285
+ - **키는 호출자가 정한다** — 락 범위(자원 단위)를 `key` 로 표현한다.
286
+ 프레임웍이 로케일 등 변이 축을 자동으로 섞지 않는다.
287
+
256
288
  ## 정본 예시
257
289
 
258
290
  회원 가입 → 환영 메일 비동기 발송 세로 조각 (§7 원문 예시):
@@ -298,11 +330,16 @@ async create() {
298
330
  발행은 `afterCommit()` 또는 아웃박스로.
299
331
  - **테스트에서 NATS 목업 금지** (§9) — 실 JetStream 에 접속한다
300
332
  (`agents/testing.md`).
333
+ - **동시 실행 방지에 로컬 뮤텍스·플래그 금지** (결정 147) — `let running = false`
334
+ 같은 프로세스 로컬 가드는 멀티 인스턴스에서 안 먹는다. `lock(key, fn)` 을
335
+ 쓴다. `redis` 미설정이면 `lock()` 이 수리 안내로 throw 하니 조용한 파손이
336
+ 없다.
301
337
 
302
338
  ## 관련 결정 번호
303
339
 
304
340
  | 결정 | 내용 |
305
341
  |---|---|
342
+ | 결정 147 | 분산 락 `lock(key, fn)` (Redis 백엔드 · 로컬 뮤텍스 반정본 · TTL 데드락 방지 · 워치독) |
306
343
  | 결정 102 | 비동기 배치 One Way 판단표 (동기 인라인 vs 잡/이벤트/스케줄 · 서두 표) |
307
344
  | 결정 103 | doctor `async-offload` 검사 (컨트롤러 인라인 메일·이미지·외부 HTTP 경고) |
308
345
  | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 모두 정합) |
@@ -300,8 +300,10 @@ methods: {
300
300
  **앞**에서 끝낸다. `withCount` 는 예외로 `include` 와 같은 자리에 실린다.
301
301
  - `groupBy` 이후는 `GroupChain` — 결과가 그룹 행이라 `first`/`all` 대신
302
302
  집계 함수가 종단이고, 레코드가 아니라 `include`·`select` 도 없다.
303
- - `join`/`leftJoin` 이후는 `JoinChain` — 반환은 자기 Rec 이라
304
- `include`·집계 그룹은 없고 스칼라 집계·`select`(자기 컬럼)·`pluck` 만.
303
+ - `join`/`leftJoin` 이후는 `JoinChain` — 반환은 자기 Rec 이라 `include`·집계 그룹은
304
+ 없지만 `where`/`whereAny`/`orWhere`/`orderBy`/`distinct`/`limit`/`offset`·스칼라 집계·
305
+ `select`(자기 컬럼)·`pluck`·`first`/`all`/**`paginate`** 는 이어진다. 그래서 **텍스트
306
+ 검색(whereAny)+관계 필터(join)+페이지네이션을 한 체인으로** 조립할 수 있다(읽기 조합).
305
307
  - `select()` 이후엔 `include` 도 없다 (부분 행에 관계를 붙이지 않는다).
306
308
  - 스코프(§8)는 `Chain` 의 어느 지점에서든 재호출 가능
307
309
  (`Post.where(...).published()` 도 됨).
@@ -490,6 +492,12 @@ export const Post = model(posts, {
490
492
  (필수 강제 · 스키마 밖 키 제거 = 대량 할당 차단)까지 한다. 컨트롤러 쪽
491
493
  사용법(폼 모양 판단·라우트 파라미터 병합)은 `agents/web.md` §3 이 정본이다.
492
494
 
495
+ **컬럼 제약(`.max(n)`·enum)도 쓰기 전 서버측에서 검증한다**(결정 153) —
496
+ `t.string().max(200)` 을 넘긴 값이 200자를 넘거나 enum 허용값 밖이면 DB 에
497
+ 닿기 전에 `ValidationError` → **422(폼 에러)** 로 마감한다. 초과 입력이 DB
498
+ 제약 위반(varchar 길이·CHECK)으로 **raw 500** 에 새지 않는다. 클라이언트
499
+ `.max`(HTML)만 믿지 말고 서버 폼 검증이 정본 방어층이다.
500
+
493
501
  **일부 컬럼만 검증해서 받으려면 `pick()`** — 지정한 컬럼만 담은 **새 폼**을
494
502
  돌려준다(원 폼 불변). 컬럼 타입·검증·기본값 정보가 그대로 따라오므로,
495
503
  "검증되는 부분 폼"이 필요할 때 애드혹 `{ _row: {} as T }`(검증 없음) 대신 쓴다.
@@ -514,6 +522,29 @@ async create() {
514
522
  동적 default(`now()`·`gen_random_uuid()`·bigserial)는 채우지 않고 DB 가 채운다.
515
523
  - `omit`·`extend`·`merge` 는 **없다** — 폼 변형은 `pick()` 하나가 The One Way.
516
524
 
525
+ ### 8.2 캐시 — 명시 TTL 만 (`cache` · `.withCache` · 결정 148)
526
+
527
+ 비싼 조회·계산 결과를 **명시 TTL** 로 캐시한다. `gaonjs/data` 에서 온다.
528
+
529
+ ```ts
530
+ import { cache } from 'gaonjs/data'
531
+
532
+ // 키가 있으면 캐시 값, 없으면 fn 을 돌려 60초 캐시.
533
+ const stats = await cache.remember('stats:home', 60, () => computeHomeStats())
534
+ await cache.forget('stats:home') // 명시 무효화
535
+
536
+ // 쿼리 종단 헬퍼 — all()/first() 결과를 캐시(키는 쿼리에서 자동 도출).
537
+ const top = await Post.published().latest().limit(5).withCache(30).all()
538
+ ```
539
+
540
+ - **자동 무효화는 없다** — 쓰기(`create`/`update`/`delete`)가 캐시를
541
+ **지우지 않는다**. 정합이 복잡하고 틀리면 조용한 stale 을 낳기 때문이다.
542
+ 신선도가 중요하면 **짧은 TTL** 을 쓰거나 `cache.forget(key)` 로 명시
543
+ 무효화한다. `.withCache` 결과는 TTL 만료로만 갱신된다(자동 퍼지 없음).
544
+ - **키는 호출자가 정한다** — 로케일·사용자 등 변이 축은 **키에 직접 넣는다**
545
+ (`` `page:${locale}` ``). 프레임웍이 변이 축을 자동으로 섞지 않는다.
546
+ - 백엔드는 Redis 가 기본(설정에 `redis` 있으면 자동), 메모리는 dev/테스트/폴백.
547
+
517
548
  ### 9. 서비스 (`service()`) — 트랜잭션 작업 흐름 (정본 §5.3 · `packages/data/src/service.ts`)
518
549
 
519
550
  로직 배치의 One Way 규칙은 루트 `AGENTS.md` 판단표가 정본이다 (정본 §5.3):
@@ -569,6 +600,13 @@ gaon db seed # domain/seed.ts 실행 (전 커넥션)
569
600
  diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션을 순회**한다
570
601
  (결정 139). `gaon db diff`(내부 `_gaon_*` 테이블은 계획에서 제외 · 결정 138).
571
602
 
603
+ > **db 명령은 프로젝트 로컬로 실행하라(`npx gaon ...` · 결정 156).** `gaon db seed` 는
604
+ > 모델 레이어를 거치므로, 전역 설치 CLI 와 프로젝트가 `@gaonjs/data` 를 각각 로드하면
605
+ > 커넥션 레지스트리가 갈려 "main 미등록" 으로 죽을 수 있다(dual package hazard). 프레임웍이
606
+ > 프로젝트 인스턴스에 자동 재등록해 대부분 자동 복구하지만, 확실히 하려면 프로젝트 로컬
607
+ > 실행(`npx gaon` · package.json 스크립트)이 정본이다. migrate/diff/status 는 모델을 안 거쳐
608
+ > 무관하다.
609
+
572
610
  **기본은 스키마 우선이다.** `domain/schema/*.ts` 를 고치고 `gaon db migrate`
573
611
  하면 diff 가 차이를 계산해 반영한다. 여기에 **손작성 마이그레이션 파일**이
574
612
  1급으로 합쳐진다(결정 39 · 합성형): `migrate` 는 ① `db/migrations/*.ts` 를
@@ -751,6 +789,11 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
751
789
  안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
752
790
  다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
753
791
  그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
792
+ - **캐시를 "쓰면 자동으로 지워진다"고 기대하면 함정** (결정 148) — `cache`·`.withCache`
793
+ 는 **자동 무효화가 없다**. `create` 후에도 같은 `cache.remember`/`.withCache` 키는 TTL
794
+ 만료 전까지 stale 을 준다. 신선도가 중요하면 짧은 TTL 이나 `cache.forget(key)`. 자동
795
+ 퍼지를 흉내 내려고 쓰기마다 forget 을 흩뿌리지 말 것(정합 복잡·조용한 stale 위험이 기각
796
+ 사유였다).
754
797
 
755
798
  ## 관련 결정 번호
756
799
 
@@ -772,4 +815,6 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
772
815
  | 결정 115 | 원자 프리미티브 increment·decrement·touch·toggle(Rec) + incrementAll·decrementAll(Chain) · read-modify-write 금지(§4) |
773
816
  | 결정 118 | `whereAny(cols, op, val)` — 다중 컬럼 동일 조건 OR 를 괄호로 묶어 AND 안전 결합(§8·정본 예시·함정) · 범용 그룹핑(whereGroup) 은 기각(복합 논리는 Kysely 탈출구) |
774
817
  | 결정 119 | `paginate(page, perPage)` — 체인 종단 `{rows,total,page,pageCount,perPage}` · 클램프·개수 number 내장 · UI 킷 Pagination 정합 · 손 조립(쿼리 2회·count 캐스팅·페이지 수학)은 반정본 · GroupChain 미탑재(행 목록 전용) |
818
+ | 결정 148 | 캐시 헬퍼 `cache.remember`/`forget`·쿼리 `.withCache(ttl)` — 명시 TTL 만 · **자동 무효화 없음**(쓰기 자동 퍼지 기각 · 조용한 stale 방지) · Redis 기본·메모리 폴백(§8.2) |
819
+ | 결정 153 | `Model.form` 컬럼 제약(`.max`·enum) 쓰기 전 서버측 검증 → 422(폼 에러) · DB 제약 위반 raw 500 방지(§8.1 · `this.params`) |
775
820
  | E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
@@ -46,8 +46,9 @@ const props = pageProps<'web:posts#index'>()
46
46
  컴파일 에러 · 결정 117). 레이아웃·컴포넌트에서 라우트를 모른 채 읽을 때 특히 유용하다:
47
47
  ```vue
48
48
  import { useShared } from 'gaonjs/vue'
49
- const shared = useShared() // { currentUser, csrf, flash } · 반응형
49
+ const shared = useShared() // { currentUser, csrf, flash, ...앱 키 } · 반응형
50
50
  // <template> 에서 shared.currentUser?.name · shared.csrf · shared.flash.success
51
+ // 앱이 app.config sharedProps 로 등록한 키(locale·theme 등)도 같은 자리에서 읽힌다(결정 150).
51
52
  ```
52
53
  `pageProps<K>()` 반환에도 교차되어 `props.csrf` 로도 읽히지만, 라우트 키가 필요 없는
53
54
  `useShared()` 가 정본 표면이다(임의 라우트 키를 빌려 currentUser 를 읽던 우회 트릭을 없앤다).
@@ -403,6 +404,7 @@ async function runSearch(q: string) {
403
404
  | 결정 109 | 서버 스키마 검증 실패 → `form.errors.<field>` 자동 반영(303 back + 플래시 · `agents/web.md` §4.1) |
404
405
  | 결정 113 | 버튼 모양 링크 = `<Button href>`(Link 로 Button 감싸지 않음 · `<a><button>` 중첩 방지 · doctor link-button-nesting) |
405
406
  | 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
407
+ | 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
406
408
  | 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
407
409
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
408
410
 
@@ -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,6 +51,13 @@
51
51
  자동으로 붙이고, 세션 secret 을 **앱별 env** `<APP>_SESSION_SECRET`(예:
52
52
  `ADMIN_SESSION_SECRET`)로 분리 배선한다(결정 141 · 앱별 세션 완전 분리).
53
53
  운영 배포 시 그 env 를 web 과 **다르게** 설정할 것.
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 한다.
54
61
  - CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF
55
62
  강제. `api()` 클라이언트는 `<meta name="csrf-token">` 을 자동으로
56
63
  읽어 `X-CSRF-Token` 헤더에 붙인다 (`packages/vue/src/api.ts:184`).
@@ -61,14 +68,33 @@
61
68
  라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
62
69
  `csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
63
70
  - JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
64
- - **인증(requireAuth) 인가(authorize) 별개 축** (결정 145):
65
- - `this.requireAuth()` = **로그인 여부** — 비로그인이면 401(세션 앱은 로그인 페이지 리다이렉트).
66
- - `this.authorize(condition)` = **권한 여부** — 조건이 거짓이면 **403**. 존재 자체를
67
- 숨겨야 하면 `this.authorize(condition, { notFound: true })` 404. 조건은 호출자가
68
- 계산한다(예: `this.authorize(this.currentUser?.role === 'admin')`) v1 은 정책 객체·
69
- 역할 DSL 두지 않는다(리치 authz 는 v1.1 백로그).
71
+ - **인증·인가는 3층이다** (결정 145 · 149):
72
+ - **① 인증 `this.requireAuth()`** = **로그인 여부** — 비로그인이면 401(세션 앱은
73
+ 로그인 페이지 리다이렉트).
74
+ - **② 저수준 인가 `this.authorize(condition)`** = **권한 여부** 조건이 거짓이면
75
+ **403**. 존재 자체를 숨겨야 하면 `this.authorize(condition, { notFound: true })` 404.
76
+ 조건은 호출자가 계산한다(예: `this.authorize(this.currentUser?.role === 'admin')`).
77
+ 일회성 규칙·탈출구다.
78
+ - **③ 정책 객체 `policy()` + `this.can`** (결정 149) — **재사용할 인가 규칙**을 리소스별
79
+ "액션 → 조건 함수"로 묶는다. 가드는 저수준 authorize 로 수렴한다:
80
+
81
+ ```ts
82
+ // domain/policies/post.ts
83
+ export const PostPolicy = policy({
84
+ update: (user, post: PostRec) => post.authorId === user.id,
85
+ destroy: (user, post: PostRec) => user.role === 'admin' || post.authorId === user.id,
86
+ })
87
+ // 컨트롤러
88
+ this.authorize(this.can(PostPolicy, 'update', post)) // 거부 → 403
89
+ if (this.can(PostPolicy, 'destroy', post)) { /* 템플릿·분기에도 */ }
90
+ ```
91
+
92
+ 정책은 **값 객체를 그대로 넘긴다**(문자열 레지스트리 아님 · 액션·리소스 타입 검사).
93
+ `this.can` 은 순수 boolean(비로그인 = 거부). 저수준 `authorize(cond)` 는 그대로 살아
94
+ 있다 — 정책은 그 위 편의층이다.
70
95
  - 실시간 채널의 `authorize` 는 **구독 인가 전용**(§realtime) — HTTP 인가는 `this.authorize`.
71
- - 손으로 403 을 throw 하거나 인가를 `notFound()` 로 우회하지 말 것 — `this.authorize` 가 The One Way.
96
+ - 손으로 403 을 throw 하거나 인가를 `notFound()` 로 우회하지 말 것 — `this.authorize`
97
+ (재사용은 `policy`)가 The One Way.
72
98
 
73
99
  ### 3. 시크릿
74
100
 
@@ -179,3 +205,6 @@ const rows = await Post.query()
179
205
  | 결정 93 (W2) | 기본 web 앱 세션 기본 배선 = CSRF 기본 켬 실태 · doctor `csrf-wiring` 경고 |
180
206
  | 결정 120 | 클라이언트 IP 신뢰 = `web.clientIp` direct/proxy/header · 헤더는 신뢰 홉 전제에서만 · IP 는 약한 신호(인가 금지) · `this.request.ip` 단일 산출(§6 · `agents/web.md` §4.4) |
181
207
  | 결정 122 | hidden 계약은 관계(`include`/지연) 행에도 적용 — render props 로 나가는 모든 값은 `serializeProps` 통과 후 hidden 컬럼명 부재 |
208
+ | 결정 145 | 인가 프리미티브 `this.authorize(cond)` — 거짓 → 403(존재 은닉 시 404) · 인증(401)과 별개 축 · 저수준 탈출구 |
209
+ | 결정 149 | 인가 정책 객체 `policy()` + `this.can` — 재사용 규칙을 리소스별 액션→조건으로 묶음 · 값 객체(레지스트리 아님) · 가드는 `authorize(can(...))` 로 수렴 · authorize(cond) 무회귀(§2) |
210
+ | 결정 155 | `gaon g auth --app <비-web>` 시큐어 기본 — 공개 회원가입 미생성 + 역할 게이트(authorize) 예시 · web=공개가입 · `--public` opt-in(§2) |
@@ -106,6 +106,12 @@ DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test`
106
106
  수동 `configureJobs` 는 필요 없다. `expectJobProcessed` 는 **자기 nats 만** 임시로
107
107
  쓰고 끝나면 하네스 배선을 복원하므로, 그 nats 를 `close()` 해도 다음 테스트의
108
108
  `.later()` 가 깨지지 않는다(결정 143).
109
+ - `connectTestDatabase` 는 **아웃박스 트랜잭션 래퍼도 배선**한다(결정 152 · `config.nats`
110
+ 있을 때) — serve·work 의 wireGaon 과 같이. 그래서 `service()` 본문의 `emit()` 이 같은
111
+ 트랜잭션으로 `_gaon_outbox` 에 적재되고, **서비스가 롤백되면 적재도 취소**된다(결정 144
112
+ 의 "롤백=미발행" 보장이 테스트에서도 참). 이 배선이 없던 때는 테스트의 emit 이 즉시발행
113
+ 경로로 새 그 보장이 **공허하게 통과**했다 — 아웃박스 롤백을 단언하는 테스트는 표준
114
+ 하네스로 그대로 돈다(수동 `setServiceTxWrapper` 불필요).
109
115
 
110
116
  스캐폴드가 심어 주는 `test/setup.ts`(수정 불필요):
111
117
 
@@ -259,6 +259,37 @@ async create() {
259
259
  }
260
260
  ```
261
261
 
262
+ #### 앱 전역 공유 키 확장 — `app.config` 의 `sharedProps` (결정 150)
263
+
264
+ 코어 3종 위에 **앱이 임의 공유 키를 얹을 수 있다**(locale·theme 등). 매 컨트롤러가
265
+ 손으로 넘기는 대신 `app.config.ts` 의 `sharedProps` 로 한 번 등록하면 그 앱의 **모든**
266
+ 렌더에 자동 주입된다.
267
+
268
+ ```ts
269
+ // apps/web/app.config.ts
270
+ export default defineAppConfig({
271
+ sharedProps: (ctx) => ({ locale: ctx.session?.locale ?? 'en', theme: 'dark' }),
272
+ })
273
+ ```
274
+
275
+ 읽는 쪽은 타입 브리지를 **선언 병합**으로 확장한다(코어 3종은 고정 · 앱 키만 추가):
276
+
277
+ ```ts
278
+ // shared/gaon-shared.d.ts (또는 아무 .d.ts)
279
+ import 'gaonjs/vue'
280
+ declare module 'gaonjs/vue' {
281
+ interface GaonSharedProps { locale: string; theme: string }
282
+ }
283
+ // 페이지에서
284
+ const { locale, theme } = useShared() // 타입 안전 · 반응형
285
+ ```
286
+
287
+ - **코어 3종(currentUser·csrf·flash)은 예약** — `sharedProps` 가 이 이름을 반환하면
288
+ 렌더가 throw 한다(코어 계약 보호). 다른 이름을 쓴다.
289
+ - 값은 렌더 경계의 `serializeProps` 를 그대로 통과한다 — **hidden 컬럼은 안 샌다**(결정 122).
290
+ - 변이 축(로케일·사용자 등)은 `sharedProps` 함수가 `ctx` 로 계산한다 — 프레임웍이 자동으로
291
+ 섞지 않는다.
292
+
262
293
  ### 4.3 읽기 조합은 컨트롤러 인라인 조립하지 않는다 (§5.3 · 결정 114)
263
294
 
264
295
  컨트롤러 액션에 허용되는 쿼리는 **스코프 체인 한 줄**까지다. 검색·태그 필터처럼
@@ -466,6 +497,7 @@ export default controller({
466
497
  | 결정 114 | 여러 모델 조합 읽기는 이름 붙임(정적 메서드/서비스) · 컨트롤러는 스코프 체인 한 줄까지(§4.3 · `agents/data.md` §8) |
467
498
  | 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `this.flash(k,v)` · 페이지는 `useShared()`(§4.2) |
468
499
  | 결정 117 | render props 에 예약 공유 키 = 컴파일 에러 + 런타임 방어(자동 주입값 조용한 덮어쓰기 금지 · §4.2) |
500
+ | 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps → 모든 렌더 자동 주입 · 코어 3종 예약(덮으면 throw) · 선언 병합 타입 · hidden 미유출 · useShared 로 읽기(§4.2) |
469
501
  | 결정 119 | 목록 액션 페이지네이션 = `chain.paginate(page, perPage)` 종단(§4.3 · `agents/data.md`) · 손 조립 반정본 · result 통째로 render props 안전 |
470
502
  | 결정 120 | 클라이언트 IP = `this.request.ip`(별도 표면 없음) · `web.clientIp` direct/proxy/header 로 rate limit·로깅과 같은 산출 배선(§4.4 · `agents/security.md`) |
471
503
  | 결정 133 | 멀티파트 업로드(`this.file()`) CSRF 는 `x-csrf-token` 헤더로만 — 바디 `_csrf` 는 스트리밍 파싱이라 검사 시점에 없다(§3 · 헤더 부재 시 403 + 수리 안내) |
@@ -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,