@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.
Files changed (72) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/check.d.ts +1 -1
  3. package/dist/commands/check.js +1 -1
  4. package/dist/commands/db.js +29 -7
  5. package/dist/commands/new.d.ts +2 -0
  6. package/dist/commands/new.js +8 -3
  7. package/dist/commands/test.js +16 -3
  8. package/dist/db/journal.d.ts +4 -3
  9. package/dist/db/journal.js +21 -10
  10. package/dist/db/migrate.d.ts +3 -1
  11. package/dist/db/migrate.js +3 -3
  12. package/dist/db/replay.js +2 -2
  13. package/dist/db/resolve.d.ts +15 -0
  14. package/dist/db/resolve.js +24 -2
  15. package/dist/db/status.js +13 -3
  16. package/dist/dev.d.ts +6 -4
  17. package/dist/dev.js +9 -4
  18. package/dist/doctor/fixers/index.d.ts +1 -1
  19. package/dist/doctor/fixers/index.js +6 -1
  20. package/dist/doctor/locale-parity.js +4 -1
  21. package/dist/doctor/pageprops-destructure.d.ts +2 -2
  22. package/dist/doctor/pageprops-destructure.js +29 -23
  23. package/dist/doctor/render-return.d.ts +11 -0
  24. package/dist/doctor/render-return.js +143 -0
  25. package/dist/doctor/types.d.ts +1 -1
  26. package/dist/doctor.d.ts +3 -2
  27. package/dist/doctor.js +17 -6
  28. package/dist/generate.d.ts +20 -1
  29. package/dist/generate.js +120 -21
  30. package/dist/hub.js +2 -0
  31. package/dist/i18n-config.d.ts +12 -0
  32. package/dist/i18n-config.js +95 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +59 -14
  35. package/dist/mcp/tools.d.ts +1 -1
  36. package/dist/mcp/tools.js +9 -6
  37. package/dist/messages-gen.d.ts +1 -1
  38. package/dist/messages-gen.js +5 -2
  39. package/dist/scaffold/job.js +3 -1
  40. package/dist/templates/auth/Dashboard.vue.tpl +3 -2
  41. package/dist/templates/auth/Login.vue.tpl +3 -5
  42. package/dist/templates/auth/Signup.vue.tpl +3 -5
  43. package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
  44. package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
  45. package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
  46. package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
  47. package/dist/templates/project/.dockerignore.tpl +3 -0
  48. package/dist/templates/project/.env.example.tpl +1 -1
  49. package/dist/templates/project/AGENTS.md.tpl +5 -3
  50. package/dist/templates/project/CLAUDE.md.tpl +5 -4
  51. package/dist/templates/project/Dockerfile.tpl +6 -1
  52. package/dist/templates/project/agents/async.md.tpl +75 -10
  53. package/dist/templates/project/agents/data.md.tpl +159 -22
  54. package/dist/templates/project/agents/frontend.md.tpl +51 -12
  55. package/dist/templates/project/agents/i18n.md.tpl +5 -2
  56. package/dist/templates/project/agents/mail.md.tpl +4 -2
  57. package/dist/templates/project/agents/realtime.md.tpl +37 -3
  58. package/dist/templates/project/agents/seal.md.tpl +14 -3
  59. package/dist/templates/project/agents/security.md.tpl +47 -11
  60. package/dist/templates/project/agents/storage.md.tpl +16 -8
  61. package/dist/templates/project/agents/web.md.tpl +78 -24
  62. package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
  63. package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
  64. package/dist/templates/project/apps/web/main.ts.tpl +1 -1
  65. package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
  66. package/dist/templates/project/docker-compose.yaml.tpl +1 -1
  67. package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
  68. package/dist/templates/project/vite.config.ts.tpl +1 -1
  69. package/dist/work.d.ts +21 -0
  70. package/dist/work.js +45 -1
  71. package/package.json +12 -7
  72. package/dist/templates/index.ts +0 -109
@@ -25,6 +25,10 @@ export const posts = table('posts', {
25
25
  **컬럼 정의가 곧 DB 타입 + TS 타입 + 검증 규칙의 단일 원천**이다
26
26
  (v0.15 §4.2 line 388 원문). 세 곳에 따로 쓰지 않는다.
27
27
 
28
+ **PK 컬럼 키는 언제나 `id`** 다(The One Way · 결정 322) — `t.id()`/`t.uuidPk()` 를 다른
29
+ 키(`userId: t.uuidPk()` 등)에 달면 `model()` 정의 시점에 수리 안내로 throw 한다.
30
+ 단건 경로(`find`·`rec.update`/`delete`·원자 프리미티브)가 `id` 컬럼을 사용한다.
31
+
28
32
  ### 1.1 관계 선언 (M2D · 결정 33)
29
33
 
30
34
  관계는 **두 자리**에 나뉘어 산다 — FK 컬럼을 **가진 쪽**은 컬럼으로,
@@ -55,7 +59,7 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
55
59
 
56
60
  | 선언 | 시그니처 | 레코드 접근 | 반환 |
57
61
  |---|---|---|---|
58
- | `t.belongsTo(table)` | `(table)` — **컬럼** | `await post.author()` | `Row` (단건) |
62
+ | `t.belongsTo(table)` | `(table)` — **컬럼** | `await post.author()` | `Row` (단건 · FK 가 `.nullable()` 이면 `Row \| null` — 결정 285) |
59
63
  | `hasMany(target, opts?)` | `(target, { foreignKey? })` | `await post.comments()` | `Row[]` |
60
64
  | `hasOne(target, opts?)` | `(target, { foreignKey? })` | `await post.cover()` | `Row \| undefined` |
61
65
  | `belongsToMany(target, opts?)` | `(target, { through?, foreignKey?, otherKey? })` | `await post.tags()` | `Row[]` |
@@ -145,7 +149,8 @@ import 하면 순환 참조가 생기므로, 실제 연결은 부팅 시 프레
145
149
  서버 코드에서는 그대로 읽힌다(직렬화에서만 제외 · §4.2). hidden 마커는 **열거
146
150
  가능한 심볼**이라 `{ ...user }` spread·`Object.assign` 을 넘어 보존된다(결정 253)
147
151
  — 단 레코드를 손으로 재구성하거나 JSON 왕복하면 마커가 사라지니 그럴 땐 hidden
148
- 컬럼을 직접 넣지 않는다.
152
+ 컬럼을 직접 넣지 않는다. `select()`/`distinct(col)` 로 hidden 컬럼을 **명시 선택**한
153
+ 좁은 행에도 마커가 실려 직렬화 경계에서 동일하게 제외된다(결정 283).
149
154
  - `.unique()` — 컬럼 레벨 UNIQUE 제약 (E-4).
150
155
  - `.index()` — 컬럼 레벨 인덱스 (E-4). 옵션 객체로 method·partial 지정 (결정 273):
151
156
  - `.index()` — 기본 인덱스. **jsonb 컬럼은 자동 gin**(유연 검색 `@>`·`?` 용 · The One Way), 그 외는 btree.
@@ -179,6 +184,9 @@ export const logs = table('logs', {
179
184
  ```
180
185
 
181
186
  - 인덱스 원소가 **문자열 배열**이면 기존과 100% 동일(btree). **객체**면 `cols`·`expr`·`using`·`where`·`name`.
187
+ - 이름 규약 `idx_<table>_<cols>` 는 method/partial 을 구분하지 않는다 — **같은 컬럼에 두 인덱스**
188
+ (예: 컬럼 `.index({where})` + 테이블 레벨 `[['col']]`)를 선언하면 이름이 충돌해 **정의 시점에
189
+ throw** 한다(결정 329). 테이블 레벨 객체의 `name` 으로 구분한다.
182
190
  - 프레임웍이 만든 인덱스는 **`idx_` 접두**만 관리한다 — raw(수동 튜닝) 인덱스는 diff 가 건드리지
183
191
  않으니(drop 계획 안 함), 손수 만든 특수 인덱스는 `idx_` 아닌 이름으로 둔다.
184
192
  - **MySQL/MariaDB(legacy §4.5) 커넥션은 gin/brin/gist·partial·표현식 인덱스가 없다** — 선언하면
@@ -208,9 +216,18 @@ export const logs = table('logs', {
208
216
  `dropPartition`·`listPartitions`. **retention(오래된 파티션 파기)은 절대 자동으로 하지 않는다** —
209
217
  `keep` 을 명시하고 그 잡을 실행할 때만 지운다(결정 39 자동 DROP 금지 정신).
210
218
  - MySQL/MariaDB 커넥션은 선언적 파티셔닝을 지원하지 않는다(migrate 시 fail-loud).
219
+ - **기존 테이블의 `partitionBy` 변경(추가·제거·전략/키 변경)은 diff/migrate 가 즉시 throw**
220
+ 한다(결정 321) — PostgreSQL 은 기존 테이블을 ALTER 로 파티션 테이블로 못 바꾼다. 전환은
221
+ 손작성 마이그로 "새 파티션 테이블 생성 → `INSERT ... SELECT` 이관 → rename 교체" 순서다.
211
222
 
212
223
  **`t.timestamps()` 와 `update()`** (결정 276): `update()` 는 `updatedAt` 을 **자동으로 현재 시각으로
213
224
  갱신**한다(patch 에 `updatedAt` 을 직접 주면 그 값을 존중). 값 변경 없이 시각만 올리려면 `touch()`.
225
+ **벌크·upsert 도 자동 갱신한다**(결정 323 · 286 유보 해소) — `updateAll()`·`incrementAll`/
226
+ `decrementAll` 과 `upsert()` 의 충돌-갱신 경로가 `updatedAt` 을 현재 시각으로 함께 갱신한다.
227
+ "update 하면 updatedAt 이 바뀐다" 가 단건/벌크 구분 없이 하나다(The One Way). patch/update 에
228
+ `updatedAt` 을 명시하면 그 값을 존중한다. **단건 원자 프리미티브(`rec.increment`/`decrement`/
229
+ `toggle`)는 대상 밖** — 카운터·플래그 노이즈가 updatedAt 의 "콘텐츠 갱신" 신호를 덮지 않게
230
+ 유지한다(필요하면 `touch()` 병행).
214
231
 
215
232
  ### 4. 체이닝 전체 (`packages/data/src/model.ts`)
216
233
 
@@ -277,12 +294,17 @@ export const logs = table('logs', {
277
294
  | 메서드 | 시그니처 | 반환 | 비고 |
278
295
  |---|---|---|---|
279
296
  | `create` | `(data)` | `Promise<Rec>` | `t.id()`·`t.timestamps()`·`.default()` 컬럼은 입력에서 선택적 (`InsertOf`) |
280
- | `batchInsert` | `(rows)` | `Promise<BulkResult>` | **벌크 삽입** (M2F · 결정 35 → BulkResult 결정 90) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · `{ count }`(삽입 행 수) 반환. 빈 배열은 DB 무접촉 `{ count: 0 }`. beforeCreate 훅은 각 row 에 적용 |
297
+ | `batchInsert` | `(rows)` | `Promise<BulkResult>` | **벌크 삽입** (M2F · 결정 35 → BulkResult 결정 90) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · `{ count }`(삽입 행 수) 반환. 빈 배열은 DB 무접촉 `{ count: 0 }`. beforeCreate 훅은 각 row 에 적용. **프리페어드 파라미터 상한(65,535)을 넘는 대량 배치는 자동 분할**되고 전체가 한 트랜잭션(원자성 유지 · 결정 325) |
281
298
  | `insertOrIgnore` | `(row \| rows)` | `Promise<BulkResult>` | **멱등 삽입** (M2F) — 충돌 행은 건너뛰고 `count` = **실제로 삽입된 행 수**(PG=`ON CONFLICT DO NOTHING` · MySQL=`INSERT IGNORE`). 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
282
299
  | `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<BulkResult>` | **있으면 갱신 없으면 삽입** (M2F) — PG=`ON CONFLICT DO UPDATE` · MySQL=`ON DUPLICATE KEY UPDATE`. `count` = 처리된 입력 행 수(삽입+갱신 · 갱신 대상 없으면 실 삽입 수). `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 덮음 |
283
300
  | `find` | `(id)` | `Promise<Rec>` | 없으면 **throw** — undefined 를 허용하려면 `where('id', '=', id).first()` |
284
301
  | `query` | `()` | Kysely `SelectQueryBuilder` | §5 탈출구 |
285
302
 
303
+ > **`upsert` 의 `onConflict` 는 MySQL/MariaDB(legacy) 방언에서 문법상 반영되지 않는다** —
304
+ > `ON DUPLICATE KEY UPDATE` 는 충돌 대상을 지정할 수 없어 **어떤 UNIQUE/PK 충돌이든** 갱신을
305
+ > 트리거한다(PG 는 지정한 컬럼 충돌만). unique 제약이 여럿인 테이블에서 방언 간 의미가 갈리니,
306
+ > legacy 커넥션의 upsert 대상 테이블은 충돌 축(unique)을 하나로 유지한다.
307
+ >
286
308
  > 벌크 3종은 **전 방언 통일 `BulkResult`**(`{ count, meta? }`)를 돌려준다(결정 90 ·
287
309
  > 원안의 "비 RETURNING 방언 throw" 폐기). `count` 는 **처리된 입력 행 수**로 방언
288
310
  > 무관 같은 의미다 — MySQL upsert 의 원시 `affectedRows`(삽입 1·갱신 2·무변경 0/1)를
@@ -445,7 +467,7 @@ export const PlaceOrder = service(async (input: { userId: string; total: number
445
467
  - **왜 `afterCommit`** — main 이 롤백되면 analytics 기록도 일어나지 않아야 한다.
446
468
  `afterCommit` 은 커밋이 성공한 경우에만 콜백을 돈다(§9 · `agents/async.md`).
447
469
 
448
- ### 7.1 문서형 컬렉션 — `collection()` (v1.1 · `@gaonjs/adapter-mongo` · 결정 278~282)
470
+ ### 7.1 문서형 컬렉션 — `collection()` (v1.1 · `@gaonjs/adapter-mongo` · 결정 278~282 · 288~292 · 331~336)
449
471
 
450
472
  SQL `model()`(Kysely) 옆에 문서형 동사 `collection()` 을 **Mongoose** 위에 둔다.
451
473
  로그·이벤트·감사·분석처럼 **문서·유연 스키마·대량 append** 용도다. **`model()` 은
@@ -459,23 +481,55 @@ npm i @gaonjs/adapter-mongo mongoose
459
481
 
460
482
  ```ts
461
483
  // domain/schema/auditLog.ts — mongoSchema() 는 진짜 Mongoose Schema 를 반환한다.
484
+ // 정본 패턴(결정 288·332): Doc + Methods + Model 인터페이스를 선언하고 제네릭 3개를
485
+ // 명시한다(mongoose 공식 타입드 statics 패턴). statics/methods 는 **옵션으로** 선언 —
486
+ // AuditLog.recent(50)·doc.summary() 가 캐스트 없이 컴파일된다.
462
487
  import { collection, mongoSchema } from 'gaonjs/data'
488
+ import type { Model } from 'mongoose'
489
+
490
+ interface AuditDoc {
491
+ actorId: string
492
+ action: string
493
+ ip?: string // hidden 이어도 서버 코드는 투명하게 읽는다
494
+ createdAt?: Date // { timestamps: true } 산출
495
+ updatedAt?: Date
496
+ }
497
+ interface AuditMethods {
498
+ summary(): string
499
+ }
500
+ interface AuditModel extends Model<AuditDoc, {}, AuditMethods> {
501
+ recent(limit: number): Promise<AuditDoc[]>
502
+ }
463
503
 
464
- const auditLogSchema = mongoSchema({
504
+ const auditLogSchema = mongoSchema<AuditDoc, AuditModel, AuditMethods>({
465
505
  actorId: { type: String, required: true, index: true },
466
506
  action: { type: String, required: true },
467
507
  ip: { type: String, hidden: true }, // 직렬화 경계 제외 (SQL .hidden() 과 동일 의미)
468
- }, { timestamps: true })
469
-
470
- // 순수 Mongoose 관례 — Gaon 이 메서드 DSL 을 재발명하지 않는다.
471
- auditLogSchema.statics.recent = function (limit: number) {
472
- return this.find().sort({ createdAt: -1 }).limit(limit).lean()
473
- }
508
+ }, {
509
+ timestamps: true,
510
+ statics: {
511
+ async recent(limit: number) {
512
+ return (await this.find().sort({ createdAt: -1 }).limit(limit).lean()) as AuditDoc[]
513
+ },
514
+ },
515
+ methods: {
516
+ summary() { return `${this.actorId}:${this.action}` },
517
+ },
518
+ })
474
519
 
475
520
  // db: 'logs' — 몽고 커넥션 바인딩 (SQL 의 { db: 'legacy' } 와 대칭)
476
521
  export const AuditLog = collection('audit_logs', auditLogSchema, { db: 'logs' })
522
+
523
+ // 사용감 — 전부 캐스트 없이 타입이 흐른다(결정 332).
524
+ // const logs = await AuditLog.recent(50)
525
+ // const doc = await AuditLog.create({ actorId: 'u1', action: 'login' }); doc.summary()
477
526
  ```
478
527
 
528
+ statics/methods 가 없으면 제네릭 없이 `mongoSchema({...})` 만으로 정의 지점 추론이
529
+ 성립한다(`new mongoose.Schema` 와 동일 · 결정 288 이 생성자 제네릭 복제로 고정).
530
+ statics 가 필요하면 **반드시 위 3-제네릭 패턴** — 제네릭 없이 statics 만 옵션에 넣는
531
+ 추론 조합은 mongoose 업스트림 타이핑이 깨져 있어(바닐라도 동일 실측) 지원하지 않는다.
532
+
479
533
  ```ts
480
534
  // gaon.config.ts — 문서형 커넥션은 adapter: 'mongodb' 로 SQL 과 나란히 선언한다.
481
535
  export default defineConfig({
@@ -488,11 +542,18 @@ export default defineConfig({
488
542
 
489
543
  **정본 규칙**:
490
544
  - **`hidden` 은 SQL 과 같은 의미** — "서버는 읽고 직렬화만 제외". `select:false` 를
491
- 쓰지 않는다. render props 로 흘릴 땐 **`.lean()`** 을 쓴다(정본 경로) — `_id`(ObjectId)는
492
- 응답 경계에서 자동으로 string 이 된다(결정 282 · 결정 37 문서형 대응).
545
+ 쓰지 않는다. render props 로 흘릴 땐 **`.lean()`** 을 쓴다(정본 경로 · 경량) —
546
+ `_id`(ObjectId)는 응답 경계에서 자동으로 string 이 된다(결정 282 · 결정 37 문서형
547
+ 대응). `create()` 반환 문서·`doc.toObject()` 를 그대로 넘겨도 직렬화 경계가
548
+ 방어한다(결정 289 — save/insertMany 훅 + toObject/toJSON transform 마커 + 응답
549
+ 경계의 문서 → POJO 정규화). **중첩 경로도 지원**한다(결정 336 · dot-path 마커):
550
+ `profile: { ssn: { type: String, hidden: true } }` → `profile.ssn` 만 제외(형제
551
+ 보존), 서브도큐먼트 배열 `items: [{ secret: {...hidden} }]` → 요소별 `secret` 제외,
552
+ 배열 요소 def 자체의 hidden(`tags: [{ type: String, hidden: true }]`) → 배열 통째
553
+ 제외. 지원 밖 위치(옵션 객체의 형제 키 아래 등)는 조용히 무시하지 않고 fail-loud.
493
554
  - **몽고 쓰기를 SQL `service()` tx 안에서 하지 말 것** — 몽고는 v1 에서 트랜잭션이
494
555
  없어, tx 안 몽고 쓰기는 `MongoCrossConnectionWriteError` 로 **막힌다**(조용한 부분
495
- 커밋 방지 · 결정 281). "커밋 후 로그" 는 `afterCommit` 으로 잇는다(§7 크로스커넥션과 동일 규율):
556
+ 커밋 방지 · 결정 281 · 331). "커밋 후 로그" 는 `afterCommit` 으로 잇는다(§7 크로스커넥션과 동일 규율):
496
557
  ```ts
497
558
  export const SignUp = service(async (input) => {
498
559
  const user = await Users.create(input) // main(SQL) 트랜잭션
@@ -502,15 +563,46 @@ export default defineConfig({
502
563
  ```
503
564
  - **SQL ↔ 문서형 관계 금지** — `belongsTo`/`hasMany` 는 SQL 전용이다. 몽고 컬렉션끼리의
504
565
  참조는 Mongoose `ref`/`populate` 로 사용자가 직접 한다(Gaon doctor 가 강제하지 않음).
505
- - **인덱스 = Mongoose 스키마 선언**(`index: true` / `schema.index()`) — dev 는 자동
506
- `ensureIndexes`. 몽고는 마이그레이션이 없다(`gaon db diff/migrate` mongo 를 건너뛴다).
566
+ - **인덱스 = Mongoose 스키마 선언**(`index: true` / `schema.index()`) — dev·test
567
+ mongoose `autoIndex` 기본값이 사용 인덱스를 만든다. **운영(NODE_ENV=production)
568
+ 은 autoIndex 기본 false**(결정 333 · §8-3) — 부팅·첫 사용 시점의 암묵 인덱스 빌드가
569
+ 없다. 운영 인덱스는 배포 절차에서 `syncMongoIndexes()`(gaonjs/data 재수출)로 명시
570
+ 동기화한다(커넥션 키 지정 가능 · 스키마에 없는 인덱스는 드랍). 커넥션 설정
571
+ `autoIndex: true/false` 명시가 항상 우선. 몽고는 마이그레이션이 없다 —
572
+ `gaon db diff/migrate`(키 생략 = 전 커넥션 순회)는 mongodb 커넥션을 **건너뛰고
573
+ skipped 로 보고**하며(결정 292), `--db <mongo키>` 명시 지정만 fail-loud 다.
574
+ - **문서형 커넥션은 DB 명이 필수** — `url` 에 경로(`mongodb://…/myapp`)를 넣거나
575
+ `database` 를 지정한다. 둘 다 없으면 부팅이 수리 안내로 throw 한다(결정 335 —
576
+ 드라이버 기본 DB `test` 로 조용히 붙던 무신호 오배선 봉합).
507
577
 
508
578
  **알려진 함정**:
509
579
  - `aggregate` 결과는 임의 projection(그룹·계산)이라 `hidden` 마커를 심지 **않는다** —
510
580
  SQL `Post.query()`(Kysely 원본) raw 탈출구가 hidden 을 우회하는 것과 동형. 문서를
511
581
  안전하게 렌더에 흘리려면 `find()/findOne().lean()` 을 쓴다.
512
- - 인스턴스 `document.save()` 크로스커넥션 가드 밖이다정본 쓰기는 static
513
- `create/update/delete`(가드 대상). tx 안에서 일이면 `afterCommit` 으로 미룬다.
582
+ - **크로스커넥션 쓰기 가드는 allowlist 반전이다(결정 331 · 281 확장)**SQL tx 안에서
583
+ 읽기 전용으로 확인된 내장(find/findOne/countDocuments/aggregate 읽기 등)만
584
+ 통과하고, **그 밖의 모든 static 호출은 fail-closed** 로 막힌다. Query 체이닝 쓰기
585
+ (`find(f).updateMany()`·`where(f).deleteMany()`)와 `aggregate` 의 `$out`/`$merge` 도
586
+ 실행 지점 pre 훅이 막는다. **읽기 전용이어도 커스텀 static 은 tx 안에서 막힌다**
587
+ (쓰기 여부 판정 불가 — 내장 읽기를 직접 쓰거나 호출을 tx 밖으로). 유일한 예외는
588
+ 인스턴스 `doc.save()`(정본 예외 · 미가드). tx 안에서 쓸 일이면 `afterCommit`.
589
+ - **같은 커넥션·같은 컬렉션명에 다른 스키마를 선언하면 첫 접근에서 throw**(결정 334)
590
+ — 조용히 첫 스키마를 재사용하지 않는다. 같은 컬렉션이면 mongoSchema 산출을 한
591
+ 모듈에서 export 해 재사용하라(스키마 객체 하나).
592
+ - **`service()` 는 SQL 전용** — `service(fn, { db: '<mongo키>' })` 는 SQL 레지스트리
593
+ 조회라 "커넥션 미등록" 에러가 난다(안내 문구도 SQL 기준). 몽고 쓰기에는 서비스
594
+ 트랜잭션 개념이 없다(v1 몽고 tx 없음) — 그냥 static 으로 쓰거나 SQL 서비스의
595
+ `afterCommit` 에서 쓴다.
596
+ - **mongoSchema 제네릭**: v1.21(adapter-mongo 0.1.0) 이하에서는 제네릭을 생략하면
597
+ 문서 타입이 `{ actorId: StringConstructor }` 로 **조용히 붕괴**했다 — 0.2.0(결정
598
+ 288)부터 정의 지점 추론이 성립한다. statics/methods 타입이 필요하면 3-제네릭 정본
599
+ 패턴(결정 332)만 쓴다 — 제네릭 없는 statics 옵션 추론은 mongoose 업스트림 타이핑이
600
+ 깨져 있다(바닐라 동일 실측 · 지원 안 함).
601
+ - **중첩 hidden 은 v1.22(adapter-mongo 0.2.0) 이하에서 fail-loud throw 였다**(결정
602
+ 290) — 0.3.0(결정 336)부터 dot-path 로 실지원된다. 지원 밖 위치(옵션 객체의 형제 키
603
+ 아래 등)의 hidden 은 여전히 fail-loud(조용한 유출 금지). 서브스키마 인스턴스
604
+ (`mongoSchema` 산출을 `type:` 에 넣은 경우)의 내부 hidden 은 부모가 수집하지 않는다
605
+ — hidden 필드는 한 mongoSchema defs 트리 안에서 선언하라.
514
606
 
515
607
  ### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts`)
516
608
 
@@ -683,6 +775,11 @@ const top = await Post.published().latest().limit(5).withCache(30).all()
683
775
  **지우지 않는다**. 정합이 복잡하고 틀리면 조용한 stale 을 낳기 때문이다.
684
776
  신선도가 중요하면 **짧은 TTL** 을 쓰거나 `cache.forget(key)` 로 명시
685
777
  무효화한다. `.withCache` 결과는 TTL 만료로만 갱신된다(자동 퍼지 없음).
778
+ - **적용 지점은 `Chain` 의 `all()`/`first()` 뿐이다** — `.withCache()` 뒤에
779
+ `include()`/`select()`/`distinct(col)`/`groupBy()`/`withCount()`/`join()`/`paginate()`
780
+ 가 오면 캐시가 적용될 수 없어 **즉시 throw** 한다(수리 안내 포함 · 결정 330 — 이전엔
781
+ 조용히 미적용). 캐시가 필요하면 include/select 없는 형태로 `all()`/`first()` 마감하거나,
782
+ 결과 조립 전체를 `cache.remember` 로 감싼다.
686
783
  - **키는 호출자가 정한다** — 로케일·사용자 등 변이 축은 **키에 직접 넣는다**
687
784
  (`` `page:${locale}` ``). 프레임웍이 변이 축을 자동으로 섞지 않는다.
688
785
  - 백엔드는 Redis 가 기본(설정에 `redis` 있으면 자동), 메모리는 dev/테스트/폴백.
@@ -705,8 +802,9 @@ export const PublishPost = service(async (postId: bigint) => {
705
802
  return published
706
803
  })
707
804
 
708
- // 컨트롤러에서:
709
- // const post = await PublishPost.call(this.params('id'))
805
+ // 컨트롤러에서 (결정 294: this.params('id') 같은 단일 키 접근 표면은 없다):
806
+ // const { id } = this.params({ _row: {} as { id: string } })
807
+ // const post = await PublishPost.call(BigInt(id))
710
808
  ```
711
809
 
712
810
  - **시그니처** — `service(handler, options?)` → `{ call(...args) }`.
@@ -820,8 +918,17 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
820
918
  비교가 안 되므로 **존재만 비교**한다(값이 바뀌어도 no-op). 허위 diff(멱등 붕괴)보다
821
919
  미검출(undershoot)이 안전하다는 원칙 — 값 변경이 필요하면 손작성 마이그로 `ALTER
822
920
  COLUMN … SET DEFAULT` 를 쓴다. (기본값 **추가/제거**·스칼라(bool·정수·문자열·enum)
823
- 값 변경은 정상 감지된다.) 제약·기본값 diff postgres 커넥션 기준이다
824
- (mysql=legacy §4.5 aux introspect 미지원 diff 생략).
921
+ 값 변경은 정상 감지된다.) **FK(belongsTo REFERENCES)는 diff 축이 아니다** FK 는
922
+ createTable/addColumn 시점에만 생성되므로(mysql addColumn FK 동반 · 결정 327),
923
+ **기존 컬럼**을 `t.belongsTo()` 로 바꾸거나
924
+ belongsTo 를 떼도 컬럼 shape(bigint)가 같아 감지되지 않는다(FK 가 조용히 미생성/잔존).
925
+ 기존 컬럼에 FK 를 걸려면 손작성 마이그로 `ADD CONSTRAINT … FOREIGN KEY` 를 쓴다.
926
+ 이 제약·기본값 diff 는 postgres 커넥션 기준이다
927
+ (mysql=legacy §4.5 는 aux introspect 미지원 → 이 diff 생략). legacy(mysql/mariadb)
928
+ introspection 은 `char(n)`→uuid·`longtext`→jsonb **휴리스틱 매핑**을 쓴다(MariaDB 가
929
+ uuid/json 을 그 물리 타입으로 저장하는 왕복 정합 · 결정 273 Bug B) — Gaon 스키마가 만든
930
+ DB 에선 정확하지만, **기존(외부) DB 의 진짜 char/longtext 컬럼**은 uuid/jsonb 로 오인돼
931
+ 허위 alterColumn diff 가 뜰 수 있다. 그런 컬럼은 스키마에 싣지 않거나 타입을 맞춘다.
825
932
  - 마이그레이션은 커넥션별로 돈다(§7 · `--db <키>`).
826
933
 
827
934
  ### 10.1 시드 — `domain/seed.ts` · `seed()` (§7)
@@ -977,6 +1084,14 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
977
1084
  안전(오타 방지)이고, 값은 op 에 맞는 타입이다. `whereAny` 로 표현 못 하는 복합 논리(컬럼별
978
1085
  다른 op·중첩 그룹)는 `Post.query()` Kysely 탈출구(§5)로 내려간다 — `whereGroup` 같은 범용
979
1086
  그룹핑 API 는 없다(선택지 증식 회피 · 결정 118).
1087
+ - **단건 원자 프리미티브는 `updatedAt` 을 갱신하지 않는다** (결정 276·323) — `update`/
1088
+ `updateAll`/`incrementAll`/`upsert` 충돌-갱신은 자동 갱신하지만, `rec.increment`/
1089
+ `decrement`/`toggle` 은 카운터 축이라 대상 밖이다. 갱신 시각까지 남기려면 `touch()` 를
1090
+ 병행하거나 patch 로 갱신한다.
1091
+ - **`.withCache()` 는 `Chain` 의 `all()`/`first()` 에만 적용된다** (결정 148·330 · §8.2) —
1092
+ 뒤에 `include()`/`select()`/`distinct(col)`/`paginate()` 류가 오면 **즉시 throw**
1093
+ 한다(조용한 미적용 대신 수리 안내). include/select 없는 형태로 마감하거나
1094
+ `cache.remember` 로 감싼다.
980
1095
  - **캐시를 "쓰면 자동으로 지워진다"고 기대하면 함정** (결정 148) — `cache`·`.withCache`
981
1096
  는 **자동 무효화가 없다**. `create` 후에도 같은 `cache.remember`/`.withCache` 키는 TTL
982
1097
  만료 전까지 stale 을 준다. 신선도가 중요하면 짧은 TTL 이나 `cache.forget(key)`. 자동
@@ -1010,7 +1125,29 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
1010
1125
  | 결정 253 | hidden 마커를 **열거 가능한 심볼**로 부여 — `{ ...row }` spread·`Object.assign` 을 넘어 보존돼 우회 유출(S1) 봉합(§1) |
1011
1126
  | 결정 265 | 조인 시 자기 테이블 컬럼 자동 한정 — `where`/`whereAny`/`orderBy` 가 자기 테이블로 한정돼 ambiguous column 방지(§4 join) |
1012
1127
  | 결정 278 | 문서형 동사 `collection()`(Mongoose) — SQL `model()` 과 분리 · `mongoSchema()` 얇은 래퍼(판별 태그+hidden 전개) · `@gaonjs/adapter-mongo`(mongoose optional peer)(§7.1) |
1128
+ | 결정 283 | `select()`/`distinct(col)` 좁은 행에도 hidden 마커 부착 — 명시 select 한 hidden 컬럼의 조용한 직렬화 유출 봉합(§3 `.hidden()`) |
1129
+ | 결정 285 | nullable FK 의 belongsTo 관계는 `Row \| null` — 지연·include 모두 null 정규화(타입 green NPE 봉합 · §1.1) |
1130
+ | 결정 286 | `updateAll()` 도 mysql jsonb 직렬화(F3 확장) · 벌크 `updatedAt` 자동 갱신 유보 → **결정 323 으로 해소** |
1131
+ | 결정 321 | 기존 테이블 `partitionBy` 변경은 diff/migrate 즉시 throw(조용한 no-op 제거 · 손작성 이관 안내 · §3) |
1132
+ | 결정 322 | PK 컬럼 키 `id` 강제 — `model()` 정의 시점 수리 안내 throw(§1) |
1133
+ | 결정 323 | `updateAll`·`incrementAll`/`decrementAll`·`upsert` 충돌-갱신도 `updatedAt` 자동 갱신(단건 원자 프리미티브 제외 · §3) |
1134
+ | 결정 325 | `batchInsert` 파라미터 상한(65,535) 초과 자동 분할 — 전체 한 트랜잭션(원자성 유지 · §4) |
1135
+ | 결정 327 | mysql `addColumn` 이 belongsTo FK 를 같은 ALTER 문으로 동반(§10) |
1136
+ | 결정 328 | `distinct(col).paginate` 의 total = 선택 컬럼 distinct 축(§4) |
1137
+ | 결정 329 | 인덱스 이름 충돌(같은 컬럼 method/partial 중복) 정의 시점 throw — `name` 으로 구분(§3) |
1138
+ | 결정 330 | `.withCache()` 무효 전이(include/select/paginate 류) 즉시 throw(§8.2·함정) |
1013
1139
  | 결정 279 | 문서형 커넥션 `adapter: 'mongodb'`(config `db` 맵) · `isTableDef` 가 collection 제외(tables.d.ts·doctor) · `gaon db` mongo 마이그 fail-loud(§7.1) |
1014
1140
  | 결정 281 | 몽고 쓰기를 SQL `service()` tx 안에서 하면 `MongoCrossConnectionWriteError` 로 막힘 — "커밋 후 로그" 는 `afterCommit`(§7.1 · 결정 221 동형) |
1015
1141
  | 결정 282 | ObjectId(`_id` 포함) → string 직렬화 정규화(응답 경계 · 결정 37 문서형 대응 · `.lean()` 권장)(§7.1) |
1142
+ | 결정 288 | `mongoSchema()` 제네릭 = Mongoose Schema 생성자 복제 — 자작 시그니처의 문서 타입 조용한 붕괴(StringConstructor) 봉합 · 정본 = 명시 인터페이스(§7.1) |
1143
+ | 결정 289 | 문서 인스턴스 경로 hidden 마커 전파 — save/insertMany 훅 + toObject/toJSON transform + 응답 경계 문서→POJO 정규화(§7.1) |
1144
+ | 결정 290 | 중첩 경로 `hidden: true` 는 fail-loud throw — top-level 전용 → **결정 336 이 실지원으로 대체**(§7.1) |
1145
+ | 결정 291 | 쓰기 가드에 `insertOne`(mongoose 8.16+)·`bulkSave` 편입 → **결정 331 allowlist 반전으로 흡수**(§7.1 · 결정 281 확장) |
1146
+ | 결정 292 | `gaon db` 전 커넥션 순회는 mongodb 를 skip + skipped 보고(exit 0) — `--db <mongo키>` 명시만 fail-loud(§7.1) |
1147
+ | 결정 331 | 쓰기 가드 allowlist 반전 — 읽기 전용 내장만 SQL tx 통과 · Query 체이닝/aggregate $out·$merge 는 실행 지점 pre 훅 · 커스텀 static 도 tx 안 fail-closed · 예외 = doc.save()(§7.1) |
1148
+ | 결정 332 | statics/methods 타입 통로 — Doc+Methods+Model 인터페이스 3-제네릭 정본 · collection() 반환이 mongoose.model() 오버로드 복제(캐스트 0)(§7.1) |
1149
+ | 결정 333 | 운영(NODE_ENV=production) autoIndex 기본 false + `syncMongoIndexes()` 명시 동기화 표면(§7.1 · §8-3) |
1150
+ | 결정 334 | 같은 커넥션·같은 이름·다른 스키마 재선언은 첫 접근 throw — 조용한 첫 스키마 재사용 봉합(§7.1) |
1151
+ | 결정 335 | 문서형 커넥션 url·database 둘 다 없으면 부팅 fail-loud — 드라이버 기본 `test` DB 무신호 접속 봉합(§7.1) |
1152
+ | 결정 336 | 중첩 hidden dot-path 실지원(290 대체) — 중첩 객체·type:{} 서브도큐먼트·서브도큐먼트 배열 · 직렬화 경계 상속 스레딩 · 지원 밖 위치는 fail-loud(§7.1) |
1016
1153
  | E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
@@ -59,7 +59,8 @@ const props = pageProps<'web:posts#index'>()
59
59
  는 매 접근마다 `usePage().props` 를 다시 읽는 **Proxy** 를 돌려준다. `const { csrf } =
60
60
  useShared()` 처럼 구조분해하면 그 순간 값을 한 번 스냅샷해 **반응성이 끊긴다**(리다이렉트·
61
61
  partial reload 후 flash·currentUser 갱신이 안 보인다). 항상 `const shared = useShared()`
62
- 로 받아 `shared.csrf`·`shared.flash` 로 접근한다.
62
+ 로 받아 `shared.csrf`·`shared.flash` 로 접근한다. useShared 구조분해도 doctor
63
+ **pageprops-destructure** 가 잡는다(`.vue` + 앱 `.ts` 공통 · 결정 302).
63
64
  - **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
64
65
  로 import 하지 않는다.
65
66
  - **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
@@ -120,13 +121,33 @@ async function search(q: string) {
120
121
  (서버 `this.params` 우선순위와 대칭 · `agents/web.md` §3).
121
122
  - **반환 타입** = 액션 반환의 `Serialized<>`. JSON 액션이 객체를
122
123
  반환하면 그 타입 그대로, render 액션이면 render props 타입이 온다.
123
- - **실패** 4xx/5xx 예외로 던진다. 422(`ValidationError`)는
124
- `err.body` 검증 이슈가 실려 있다.
124
+ `this.json(data)` 액션은 **data 타입**이 온다(`{ json, status }` 래퍼가
125
+ 아님 — 서버가 data 만 실어 보낸다 · `status` HTTP 상태로만 · 결정 299).
126
+ 액션이 조건부 `this.redirect(...)` 를 섞어도 render props 타입은 살아남는다
127
+ (유니온 멤버별 분배 · 결정 299).
128
+ - **실패** — 4xx/5xx 는 `ApiError` 로 던진다(결정 304). 캐스트 대신
129
+ `isApiError(e)` 로 좁혀 `e.status`·`e.body` 를 읽는다. 422(`ValidationError`)는
130
+ `e.body` 가 `ApiValidationBody`(`{ error, message, issues }`) 모양이다:
131
+ ```ts
132
+ import { api, isApiError, type ApiValidationBody } from 'gaonjs/vue'
133
+ try { await api('web:posts#create', { title }) }
134
+ catch (e) {
135
+ if (isApiError(e) && e.status === 422) {
136
+ const { issues } = e.body as ApiValidationBody // field·source·이유
137
+ }
138
+ }
139
+ ```
125
140
  - **CSRF** — 세션 앱은 상태 변경 요청(POST/PUT/PATCH/DELETE)에 CSRF 토큰을
126
- 자동으로 `X-CSRF-Token` 헤더에 붙인다(손수 넘길 필요 없음 · 결정 166). 정본
127
- 출처는 data-page `props.csrf` 공유 prop(결정 116 · `useForm({ _csrf: shared.csrf })`
128
- **같은 단일 출처**)이고, `<meta name="csrf-token">` 레거시 폴백이다(표준 셸은
129
- 내지만 앱이 손수 넣었으면 존중 · `packages/vue/src/api.ts` · `agents/security.md`).
141
+ 자동으로 `X-CSRF-Token` 헤더에 붙인다(손수 넘길 필요 없음 · 결정 166·341). 출처는
142
+ **라이브 Inertia 페이지 props csrf**(결정 341 · `useShared().csrf` 와 같은 단일
143
+ 출처 · `packages/vue/src/csrf.ts`) 로그인(세션 재생성 · 결정 254) 후에도 항상
144
+ 최신이다. 최초 문서 data-page `<meta name="csrf-token">` 부팅 전 폴백.
145
+ `useForm`/`router` 제출도 같은 자동 부착을 받는다(결정 342 — 페이지가 `_csrf`
146
+ 바디·수동 헤더를 싣지 않는다 · `agents/security.md`).
147
+ - **파라미터 배열(결정 343)** — `api('web:posts#index', { tags: ['a', 'b'] })` 처럼
148
+ 배열 값을 넘길 수 있다. GET 은 중복 키(`tags=a&tags=b` · 서버 `this.params` 의
149
+ "중복 키 = 배열" 규칙과 대칭), 본문은 문자열화된 JSON 배열로 실린다. `:param`
150
+ 자리표시자엔 스칼라만(배열이면 수리 안내 throw).
130
151
 
131
152
  ### 3. bigint PK 식별자 — 컨트롤러에서 `String()` 정규화 (결정 37)
132
153
 
@@ -417,10 +438,12 @@ async function runSearch(q: string) {
417
438
 
418
439
  - **`usePage()`·`defineProps<T>()` 로 pageProps 대체 금지** — Serialized
419
440
  경계 우회로 타입 안전 붕괴.
420
- - **`pageProps()`·`useShared()` 는 setup 컨텍스트 전용** `<script setup>` 최상위
421
- (또는 컴포저블)에서 호출해야 `usePage()` inject 성립한다(`pageProps.ts`). 이벤트
422
- 핸들러·`await` 뒤·setup 밖에서 부르면 inject 없어 깨진다. setup 에서 번 잡아
423
- (반응형 프록시라 그대로) 쓴다.
441
+ - **`pageProps()`·`useShared()` 는 `<script setup>` 최상위에서 잡는다**
442
+ 결정 300: usePage(@inertiajs/vue3 3.6) 모듈 싱글턴이라 호출 자체는 어디서든
443
+ 성립하지만(inject 아님), **앱 부팅(createGaonApp 마운트) 전에 속성에 접근**하면
444
+ 페이지가 아직 없어 수리 안내 에러가 난다(모듈 최상위에서 잡는 실수의 실제 실패
445
+ 모드). 관례는 setup 최상위에서 한 번 잡아(반응형 프록시라 그대로) 쓰는 것 —
446
+ 이벤트 핸들러마다 다시 부를 필요도 없다.
424
447
  - **`api()` 에 제네릭 인자 직접 붙이지 않는다** — key 리터럴이 타입을
425
448
  결정한다.
426
449
  - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
@@ -446,7 +469,11 @@ async function runSearch(q: string) {
446
469
  재구현하다 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에. 소켓이 끊기면
447
470
  useChannel 이 지수 백오프로 **자동 재접속**하고 프레즌스를 재동기한다(결정 128 · 기본
448
471
  켬 · `status='reconnecting'` · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401 은
449
- 재연결 안 함). 손 WebSocket 재연결 루프를 짜지 말 것.
472
+ 재연결 안 함). 손 WebSocket 재연결 루프를 짜지 말 것. 결정 303: `send()` 는 소켓이
473
+ OPEN 이 아니면 보내지 않고 `false` 를 반환한다(큐잉 없음 — 유실 불가 송신은 반환값
474
+ 확인). 컴포넌트 **밖**에서 부르면 즉시 접속되지만 자동 정리가 없어 호출자가
475
+ `close()` 를 책임진다(기본 배치는 setup 안). 오래 사는 채널은 `maxMessages` 로
476
+ `messages` 상한을 잡는다(`agents/realtime.md` §4).
450
477
  - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
451
478
  중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
452
479
  - **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
@@ -495,6 +522,18 @@ async function runSearch(q: string) {
495
522
  | 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
496
523
  | 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §7) |
497
524
  | 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
525
+ | 결정 299 | 타입 브리지 PropsOf 정정 — 유니온 분배(조건부 redirect 혼합 액션의 never 붕괴 봉합) + `this.json(data)` 언랩(`{json,status}` 래퍼 타입 거짓 봉합 · `JsonResult<T>` 제네릭) (§2) |
526
+ | 결정 300 | pageProps/useShared 부팅 전 접근 가드(수리 안내) + setup-only 근거 정정(usePage=모듈 싱글턴 · inject 아님 · 실측) (§1·알려진 함정) |
527
+ | 결정 301 | `useShared()` Proxy 열거 트랩 정합 — 스프레드/Object.keys/JSON.stringify 빈 객체 봉합 · 코어 3종 `in` 정합(pageProps 4-트랩과 동일 계약) (§1) |
528
+ | 결정 302 | doctor pageprops-destructure 확장 — `useShared` 구조분해 + 앱 `.ts`(컴포저블) 순회(문서-검사 갭 봉합) (§1) |
529
+ | 결정 303 | `useChannel` 계약 3정비 — `send()` OPEN 아니면 `false` · 컴포넌트 밖 = 즉시 접속(정리는 호출자) · `maxMessages` 상한 (`agents/realtime.md` §4) |
530
+ | 결정 304 | `api()` 실패 타입 공개 — `ApiError`·`isApiError`·`ApiValidationBody`(422 issues 타입드) (§2) |
531
+ | 결정 305 | `createGaonApp` 마운트 대상(`#app`) 부재 = 수리 안내 throw(조용한 빈 화면 봉합) |
532
+ | 결정 341 | CSRF 토큰 출처 = 라이브 Inertia 페이지 props(`csrf.ts` · 최초 문서·meta 는 부팅 전 폴백) — 로그인 세션 재생성 후 api() 403 stale 봉합(§2) |
533
+ | 결정 342 | `useForm`/`router` 상태 변경에 `X-CSRF-Token` 자동 부착(라이브 출처 · api() 와 단일화) — `_csrf` 수동 보일러플레이트 제거 · 명시 헤더 존중 · 우회 전송은 `readCsrfToken()` 탈출구 |
534
+ | 결정 343 | `ApiParams` 배열 지원 — GET 중복 키·본문 JSON 배열(서버 중복 키=배열 규칙 대칭) · `:param` 은 스칼라만(§2) |
535
+ | 결정 344 | `useChannel` 함수형 `params` — 접속·재접속 시점마다 평가(JWT access_token 회전 반영 · `agents/realtime.md` §4) |
536
+ | 결정 345 | `PageMap` 항목 타입 의도 문서화(`PageMapEntry` — 지연 로더·eager 모듈 · vite glob unknown 제약 명기) |
498
537
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
499
538
 
500
539
  ## `@gaonjs/seal` 켠 앱의 프론트
@@ -71,7 +71,7 @@ export default defineConfig({
71
71
  i18n: {
72
72
  fallbackLng: 'ko',
73
73
  supportedLngs: ['ko', 'en', 'ja'], // 생략 시 locales/ 폴더 하위 언어들
74
- // dir: 'locales', // 생략 시 'locales'
74
+ // dir: 'locales', // 생략 시 'locales' — 타입 축·doctor 도 이 값을 따른다(결정 352)
75
75
  // detect: { cookieName: 'gaon_locale', priority: ['session', 'cookie', 'header'] }, // 기본값
76
76
  },
77
77
  })
@@ -200,7 +200,8 @@ CSS 가 올바른 언어를 안다). 비-i18n 프로젝트는 템플릿 정적
200
200
 
201
201
  ### 7. 로케일 커버리지 — `locale-parity` 경고 (결정 216)
202
202
 
203
- `messages.d.ts` 의 키 유니온은 **기준 로케일 하나**에서 나온다(§4). 그래서 어떤 키를
203
+ `messages.d.ts` 의 키 유니온은 **기준 로케일 하나**에서 나온다(§4 · 기준 = `fallbackLng` — 결정 352 ·
204
+ 이전엔 알파벳순 첫 로케일이라 컴파일 보증이 fallback 체인과 다른 로케일에 정박했다). 그래서 어떤 키를
204
205
  `ko.json`·`en.json` 에는 넣고 `ja.json` 에만 빠뜨리면 **컴파일은 통과**하고, 런타임에
205
206
  일본어 사용자만 fallback(대개 다른 언어) 번역을 조용히 본다 — 타입도 화면도 못 잡는
206
207
  사각이다. `gaon doctor`/`gaon check` 의 `locale-parity`(§2.2 27번)가 `locales/` 의
@@ -264,3 +265,5 @@ export const SendDigest = job(async ({ userId, locale }: { userId: bigint; local
264
265
  | 결정 213 | Vue 클라 소비 = 서버 주도 render props/sharedProps 만(§5) · `t()`·`useT()` 클라 미노출(서버 ALS 전용) · sharedProps 는 결정 150 동형 |
265
266
  | 결정 214 (13차 W2) | 최초 문서 셸 `<html lang>` 이 요청 협상 로케일 자동 추종(§6) · 템플릿 정적값은 기본값 · 비-i18n 무회귀 |
266
267
  | 결정 216 (13차 W4) | `locale-parity` doctor 경고(§7) — 로케일 간 키 부분 누락 = fallback 조용 노출 · 기준 로케일 유니온의 사각 |
268
+ | 결정 352 | messages.d.ts 기준 로케일 = `fallbackLng`(정적 분석 전달) · `i18n.dir` 을 타입 축(gen/dev/check)·locale-parity 가 존중(하드코딩 'locales' 제거) |
269
+ | 결정 353 | 부팅 fail-loud — i18n 설정 + 빈 카탈로그(전 화면 raw 키 방지) · `fallbackLng`/`supportedLngs` 가 지목한 로케일 파일 통째 부재(조용한 fallback 언어 대체 방지) 는 부팅 에러 + 수리 안내 |
@@ -56,7 +56,8 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
56
56
 
57
57
  `gaon dev` 의 compose 가 MailPit 을 띄운다(SMTP 캡처 + 웹 UI `:8025`) — 개발 중
58
58
  보낸 메일은 실제로 나가지 않고 MailPit 수신함에서 확인한다. 스캐폴드 `gaon.config.ts`
59
- ·`.env.example` 에 mail 블록이 이미 있어(결정 160) `cp .env.example .env` 바로 돈다.
59
+ ·`.env.example` 에 mail 블록이 이미 있고 `.env` `gaon new` 자동 생성하므로
60
+ (결정 160·198) 바로 돈다.
60
61
  운영은 `SMTP_HOST`·자격증명·`SMTP_SECURE=true` 로 교체(같은 코드).
61
62
 
62
63
  - **미리보기 정본 = MailPit** 이다 — 조용히 메모리로 삼키는 `captureTransport`(in-memory 싱크)는 **프레임웍 내부 테스트 전용**이라 파사드(`gaonjs/mail`)에 노출하지 않는다(결정 238 · 프로덕션 오배선 시 무신호 유실 방지).
@@ -89,7 +90,8 @@ export default defineConfig({
89
90
  | `host` | `string` | SMTP 호스트(dev = MailPit `127.0.0.1`) |
90
91
  | `port` | `number` | SMTP 포트(dev MailPit = 1025) |
91
92
  | `secure?` | `boolean` | TLS(운영). 생략 = false |
92
- | `user?`·`pass?` | `string` | 인증 SMTP 자격증명(생략 가능) |
93
+ | `user?`·`pass?` | `string` | 인증 SMTP 자격증명(생략 가능 · **둘 다 또는 둘 다 없음** — 한쪽만 있으면 부팅 fail-loud · 결정 357) |
94
+ | `requireTls?` | `boolean` | STARTTLS **강제**(결정 357). 기본 STARTTLS 는 기회적이라 서버가 광고 안 하면 평문 진행 — 자격 있는 운영 전송은 `secure: true`(465) 또는 이걸 켠다 |
93
95
  | `defaultFrom` | `string` | 발신자 기본값 — 메시지에 `from` 이 없을 때. `configureMailer({ defaultFrom })` 와 동치 |
94
96
 
95
97
  ## 정본 예시
@@ -86,7 +86,10 @@ export default channel({
86
86
  - `onMessage` throw → 그 연결에 **에러 프레임(`{ t:'error' }`)을 보내고 `1011` 로 종료**한다
87
87
  (요청 실패지만 서버는 생존 = HTTP 500 대칭).
88
88
  - `onLeave` throw → 연결이 **이미 닫히는 중**이라 통지할 곳이 없다. **로그만 남기고 정리(프레즌스
89
- 해제 )를 계속**한다(추가 종료·에러 프레임 없음).
89
+ 해제·로컬 연결 회수)를 계속**한다(추가 종료·에러 프레임 없음 · 결정 309 — 정리는 finally 라
90
+ 예외에도 누수가 없다).
91
+ - `onJoin`(또는 접속 직후 스냅샷 조회) throw → 연결은 join 실패(`1011`)로 닫히고, **이미 등록된
92
+ 프레즌스는 자동 보상 해제**된다(결정 307) — 접속하지 못한 멤버가 로스터에 유령으로 남지 않는다.
90
93
 
91
94
  재시도가 필요한 로직은 핸들러 안에서 try/catch 로 감싸 직접 통제한다.
92
95
 
@@ -180,7 +183,10 @@ const members = await ctx.presence()
180
183
  URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{ t:'msg', data }`)
181
184
  감싸기/풀기·마운트 접속·언마운트 정리·반응형 상태를 한 번에 준다. `new WebSocket`
182
185
  을 손으로 짜지 말 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
183
- 자동 인증, JWT 앱은 `params: { access_token }`.
186
+ 자동 인증, JWT 앱은 `params: () => ({ access_token: token.value })` — **함수형으로
187
+ 넘겨라**(결정 344). params 는 접속·재접속 시점마다 평가되므로 함수형이면 회전한
188
+ 토큰이 재연결에 반영된다(고정 객체는 최초 값 고정 — 토큰 만료 후 드롭 시 4401
189
+ 영구 종료). room=42 같은 불변 값은 고정 객체로 넘겨도 된다.
184
190
 
185
191
  **앱 프리픽스는 자동이다(결정 154).** 서버는 채널 WS 를 `<앱 프리픽스>/gaon/ws/:channel`
186
192
  에 등록하고, `useChannel` 은 그 앱 번들의 `import.meta.env.BASE_URL`(= vite base = 앱
@@ -208,7 +214,11 @@ export function useRoom(roomId: number) {
208
214
  ```
209
215
 
210
216
  - **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
211
- 정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
217
+ 정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다. **소켓이 OPEN 이 아니면
218
+ (접속 전·재연결 중) 보내지 않고 `false` 를 반환한다**(결정 303 · 큐잉 없음) — 유실이
219
+ 안 되는 송신은 반환값을 확인해 재시도/안내한다.
220
+ - **메시지 상한** — `messages` 는 기본 무제한 축적이다. 채팅·대시보드처럼 오래 사는
221
+ 채널은 `maxMessages: 200` 처럼 상한을 잡는다(초과분은 오래된 것부터 버림 · 결정 303).
212
222
  - **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출. 접속자 프레임(초기
213
223
  스냅샷 + 이후 들어옴/나감)은 하나의 **명단**으로 합쳐져 반응형 `members` 에 반영되고
214
224
  `onPresence(members)` 로도 통지된다(결정 272 · 콜백만으로 항상 최신 명단 · 델타 병합
@@ -256,6 +266,19 @@ export function useRoom(roomId: number) {
256
266
  스케줄러(고정 5s)는 서로 다른 KV 버킷(`gaon_lease_<역할>`)을 써 각자 TTL 을
257
267
  가진다 — 한 버킷을 공유하던 시절의 TTL 플래핑(부팅 순서 의존)이 없다.
258
268
  허브 TTL 을 튜닝해도 스케줄러 failover 타이밍에 영향을 주지 않는다.
269
+ - **멀티호스트는 `GAON_HUB_ADVERTISE` 필수.** KV 에 공지하는 도달 주소의 기본은
270
+ `127.0.0.1:<port>` 라 **단일 호스트 전용**이다 — 웹서버가 다른 호스트에 있으면
271
+ 자기 localhost 로 붙으려다 무한 백오프에 빠진다. 웹서버가 도달 가능한 주소
272
+ (예: `hub.internal:4001`)를 `GAON_HUB_ADVERTISE` 로 준다(`docs/guides/operations.md`).
273
+ - **허브 TCP 포트는 내부망 전용이다(결정 311).** 포트(기본 4001)는 방화벽/
274
+ 보안그룹으로 웹서버 대역에만 연다. 프로토콜 위반 백스톱으로 라인 길이 상한
275
+ (1MiB)을 두며, 초과 소켓은 즉시 끊는다(fail-closed · 개행 없는 스트림의
276
+ 메모리 증식 차단).
277
+ - **공유 토큰 인증(선택 · 결정 350).** `GAON_HUB_TOKEN` 을 허브·웹서버 양쪽에
278
+ 설정하면 웹서버 소켓의 첫 명령이 올바른 `auth` 여야 하고, 미인증 명령·오토큰은
279
+ 즉시 종료된다(fail-closed · 도달 가능한 임의 피어의 로스터 위조 방어). 미설정
280
+ 이면 종전(무인증 · 내부망 가정). 토큰 없는 허브는 auth 를 무시하므로 웹서버에
281
+ 먼저 설정해 둬도 무해하다(무중단 롤아웃: 웹서버 → 허브 순).
259
282
 
260
283
  ## 정본 예시
261
284
 
@@ -322,6 +345,11 @@ export default channel({
322
345
  스냅샷과 별개로 받는다. `useChannel` 은 `members` 를 `id` 로 키잉해 이 중복을
323
346
  **멱등**으로 흡수하므로 명단이 불어나지 않는다(결정 272). 직접 `new WebSocket`
324
347
  을 쓰는 탈출구에서는 스스로 `id` 로 dedup 한다.
348
+ - **`useChannel` 을 컴포넌트 밖에서 부르면 자동 정리가 없다** — 컴포넌트 setup
349
+ 안이면 마운트 접속·언마운트 정리가 자동이다. 밖(모듈 스코프 store 등)이면 즉시
350
+ 접속은 되지만(결정 303 — 과거엔 라이프사이클 훅이 발화하지 않아 **영원히 closed**
351
+ 인 무신호 미접속이었다) 언마운트 정리가 없으므로 **호출자가 `close()` 를 책임**진다.
352
+ 기본 배치는 컴포저블 → 페이지/컴포넌트 setup 에서 호출.
325
353
  - **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
326
354
  (`agents/testing.md`).
327
355
 
@@ -340,6 +368,12 @@ export default channel({
340
368
  | 결정 259 | 허브 디스커버리 endpoint 는 소유 리더만 삭제(§5) — addr 일치 + revision CAS · standby 종료·리더 교대가 활성 endpoint 를 지우지 않음(재접속 서버 허브 발견 보존) |
341
369
  | 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
342
370
  | 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
371
+ | 결정 303 | `useChannel` 계약 3정비(§4) — `send()` 는 OPEN 아니면 `false`(무신호 드롭 봉합 · 큐잉 없음) · 컴포넌트 밖 호출 = 즉시 접속(라이프사이클 훅 미발화로 영원히 closed 이던 무신호 미접속 봉합 · 정리는 호출자 `close()`) · `maxMessages` 상한 옵션(초과분 오래된 것부터 버림) |
372
+ | 결정 344 | `useChannel` 함수형 `params`(§4) — 접속·재접속 시점마다 평가해 회전 토큰(JWT access_token) 반영 · 고정 객체는 최초 값 고정이라 만료 후 재연결이 4401 영구 종료되던 갭 봉합 |
373
+ | 결정 307 | `onJoin`/스냅샷 실패 = 프레즌스 보상 해제(§2) — join 후반 실패 시 이미 발신한 프레즌스 등록을 자동 회수(leave)·로컬 연결 정리 후 rethrow · 접속 못 한 멤버가 로스터에 유령으로 남던 결함 봉합 |
374
+ | 결정 309 | `onLeave` throw 에도 로컬 정리 계속(§2) — conns 회수·채널 teardown 을 finally 로 · "로그만 남기고 정리를 계속" 문서 계약과 코드 정합(conn·구독 누수 봉합) |
375
+ | 결정 311 | 허브 TCP 라인 상한 + 내부망 명문화(§5) — 개행 없는 스트림의 무한 버퍼링을 1MiB 상한으로 차단 · 초과 소켓 즉시 종료(fail-closed) · 허브 포트는 방화벽으로 내부망 한정 |
376
+ | 결정 350 | 허브 공유 토큰 인증(§5 · 선택) — `GAON_HUB_TOKEN` 설정 시 첫 명령 = `auth` 강제(타이밍 세이프 비교) · 미인증/오토큰 즉시 종료 · 토큰 없는 허브는 auth 무시(혼재 롤아웃 호환) |
343
377
 
344
378
  ## `@gaonjs/seal` 켠 앱의 채널
345
379
 
@@ -71,7 +71,7 @@ seal 은 이들 중 어느 것의 이유도 되지 못한다:
71
71
  ```ts
72
72
  // 2) apps/<앱>/app.config.ts — 앱 wire 전체 봉인(요청/응답 JSON + 최초 문서 data-page).
73
73
  export default defineAppConfig({
74
- seal: true, // 또는 { except: ['/webhooks/*'] } — 외부가 seal 을 모르는 경로만 평문 통과
74
+ seal: true, // 또는 { except: ['/webhooks/*'], strictQuery: true } — except = 외부가 seal 을 모르는 경로만 평문 통과 · strictQuery = 평문 쿼리도 거부(결정 354)
75
75
  })
76
76
  ```
77
77
  ```ts
@@ -115,11 +115,15 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
115
115
  소비해 봉인이 깨진다. 클라 개봉 실패는 조용히 무시하지 않고 소켓을 **4500 종료**(`useChannel` · 아래 fail-closed).
116
116
  - **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON 도 Inertia 도 아닌 HTML
117
117
  직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
118
- 모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
118
+ 모르는 경로는 `seal: { except: ['/webhooks/*'] }`. except 글롭·기본 헬스 제외는 **앱 상대 경로**로
119
+ 매칭된다(prefix 앱도 정본 예시 그대로 동작 · 전체 경로 매칭도 병행 — 결정 354).
119
120
  - **fail-closed (403·413 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
120
121
  **403 SealError** — 평문 통과 절대 없음. **과대 요청 본문(상한 초과 · `PAYLOAD_TOO_LARGE`)만 예외로 413**
121
122
  (`readStream` OOM 방어 · `errors.ts`). WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
122
- fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단).
123
+ fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단
124
+ 서버발 4500 도 close code 로 동일 판정 · 결정 318). 서버의 WS **에러 통지 프레임도 봉인**해 송신한다
125
+ (결정 318 — 평문 통지는 에러 문자열 wire 노출 + 클라 오도 "개봉 실패" 였다). 핸들러 실패(1011)는
126
+ transient 라 재연결한다 — 4500(봉인 계약 위반)만 종단.
123
127
 
124
128
  ### 3. CSP (결정 124)
125
129
 
@@ -204,6 +208,11 @@ export default controller({
204
208
  4. **"seal 켰으니 검증 느슨" = 가짜 안심** — 서버 방어층(§0) 전부 유지. seal 은 이유가 못 된다.
205
209
  5. **반쪽 봉인 금지** — "wire 전체 봉인" 기대. HTTP 만 봉인하고 WS 를 빼먹지 말 것(seal 앱 namespace 는 requireDecrypt).
206
210
  6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
211
+ 7. **클라이언트 시계 skew > 60초 = 그 사용자에게 앱 전체 403/4500** — 봉인 검증은 timestamp drift ±60s 를 강제한다(§4). 기기 시계가 어긋난 사용자는 모든 요청이 `drift` 403(WS 는 4500)으로 거부된다 — 서버 장애가 아니니 "기기 시계(자동 설정) 확인" 을 최종 사용자 안내에 포함하라.
212
+ 8. **리버스 프록시의 Host 재작성 금지** — 서버 키 시드는 `Host` 헤더, 브라우저는 `location.hostname` 을 쓴다. 프록시가 Host 를 upstream 이름으로 바꾸면 키가 갈려 data-page 개봉 실패(blank)·전 요청 403 이 된다. 프록시는 원 Host 를 보존해야 한다(`proxy_set_header Host $host` 류 · `x-forwarded-host` 는 참조하지 않는다).
213
+ 9. **쿼리 봉인은 기본 비강제(경계)** — 봉인 강제 요청이라도 `?q=` 없는 평문 쿼리는 그대로 통과한다(body 는 평문이면 403 강제 — 비대칭). 클라 인터셉터를 안 탄 인바운드(직접 URL 등)의 쿼리는 평문일 수 있다 — "인바운드 쿼리까지 봉인 보장" 으로 서술하지 말 것. 인바운드 평문 쿼리까지 거부하려면 `seal: { strictQuery: true }`(결정 354 · 403 `plaintext_query`) — 직접 URL 로 쿼리를 싣는 경로는 except 로 빼야 한다.
214
+ 10. **body 상한 ≈ 768KB** — 봉인 본문 상한은 1MiB 고정(base64 팽창 ×4/3 → 실효 평문 ≈768KB)이고 현재 프레임웍 배선은 이 값을 노출하지 않는다. 대용량 페이로드는 파일 스토리지(멀티파트는 body 봉인 예외 · §5.2) 경로로 우회하라.
215
+ 11. **클라 인터셉터는 except 를 모른다** — `gaonjs/vue` 인터셉터는 모든 same-origin 요청의 쿼리를 `?q=` 로 봉인하는데, 서버는 excluded 경로에서 개봉을 건너뛴다. seal 앱 **자신의 브라우저 코드가 except 경로를 쿼리와 함께 호출**하면 핸들러가 `q=<암호문>` 을 받고 실 파라미터는 소실된다(무에러 오동작). except 경로는 외부 호출자 전용으로 두고 앱 자신은 호출하지 말 것(클라 except 전파는 백로그 DEFER).
207
216
 
208
217
  ## 관련 결정 번호
209
218
 
@@ -214,3 +223,5 @@ export default controller({
214
223
  - **결정 223** — **HTTP replay Redis 없으면 조용히 off + 허위 주석 P1** 수정: `normalizeSealConfig` 이 nonceStore 없으면 `replay=null` 로 두어 nonce 검사가 사라지고 drift(±60s)만 남아 60초 내 재전송이 통과했다(`sealBridge` 주석은 "in-memory 폴백" 이라 거짓 단언 — `MemoryNonceStore` 는 export 만·미배선). HTTP replay 를 **항상 배선**한다 — Redis 있으면 재사용(멀티 인스턴스 안전), 없으면 in-memory 폴백(단일 인스턴스 전용) + 부팅 경고. **기각: 부팅 throw(옵션 A)** — 기본 배포가 워커 1(CLAUDE 규칙 6)이라 단일 인스턴스 in-memory 가 정상 경로인데 throw 는 dev·단일 인스턴스 seal 앱을 깨고 문서(§4 "in-memory 폴백 = 단일 인스턴스 전용")와 상충. 폴백+경고가 비파괴적·정본 정합.
215
224
  - **결정 224** — **최초 문서 data-page 평문 유출 P0** 수정: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel 로 특정해 봉인하고, 봉인 후에도 평문 data-page 잔재가 남으면 fail-closed 로 throw(§2·§5). seal 풀스택/브라우저 e2e 를 blocking 배포 게이트에 편입.
216
225
  - **결정 248** — seal/web **에러 핸들러 단일화**(FSTWRN004): seal 플러그인은 자기 `setErrorHandler` 를 등록하지 않고(`installErrorHandler:false`) web 스코프가 하나만 등록한다. **seal 배선 코드는 자체 에러 핸들러를 달지 말 것**(중복 등록 = FSTWRN004 · 아키텍처 경계 · §4).
226
+ - **결정 354** — seal 백로그 2건: ① **prefix 앱 except 무력** 수정 — except 글롭·기본 헬스 제외를 **앱 상대 경로**로도 매칭(전체 경로 매칭 병행 · 하위 호환). 배선부(web)가 앱 prefix 를 normalizeSealConfig 로 전달. ② **strictQuery 옵션** 신설 — 봉인 강제 요청의 평문 쿼리를 403 `plaintext_query` 로 거부(기본 off — 직접 URL 인바운드가 흔해 기본 강제는 정당한 요청을 깬다). 클라 인터셉터 except 전파는 DEFER(함정 11).
227
+ - **결정 318** — **WS 에러 통지 프레임 codec 경유** 수정: 서버의 에러 통지(`{t:'error'}` · onMessage 실패 1011 / 개봉 실패 4500)가 codec 을 우회해 평문으로 나가 ① 에러 문자열 wire 평문 노출 ② 클라 wsDecode 의 오도성 "개봉 실패" ③ 일시적 1011 에도 seal 앱 채널만 영구 종료(비-seal 은 재연결)를 낳았다. 통지도 `codec.encode` 로 송신하고(encode 실패 시 통지 생략 — 평문 폴백 금지), `useChannel` 은 서버발 **4500 을 close code 로 직접 종단 판정**한다(결정 222 계약이 평문 프레임 부작용에 기대지 않게).