@underdogai/mesh-event-schemas 0.1.0 → 0.2.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/src/events.ts CHANGED
@@ -142,6 +142,32 @@ export const AgentCapabilityContentSchema = z.object({
142
142
  });
143
143
  export type AgentCapabilityContent = z.infer<typeof AgentCapabilityContentSchema>;
144
144
 
145
+ // --------------------------------------------------------------------------
146
+ // org.corp.<app>.acl (상태 이벤트, state_key = "" — per-액션 ACL, C1)
147
+ // --------------------------------------------------------------------------
148
+ // 선언은 데이터(방 상태), 판정은 서버 게이트(evaluateAction) — 원칙 1. 방 운영자가 이 상태를 써서
149
+ // "이 방에서 이 액션은 이 사람들만"을 선언하면, 호스트 게이트가 dispatch 시점에 읽어 sender 를 대조한다.
150
+ // 쓰기 자체는 Matrix PL(state_default)로 보호된다. 항목이 없는 action_id 는 제한 없음(기본 허용 —
151
+ // scope/PL 게이트는 그대로 적용). 클라의 버튼 숨김/비활성은 UX 힌트일 뿐이다.
152
+
153
+ /** 한 액션의 ACL — allow: 허용 MXID 목록(빈 배열 = 아무도 못 부름, fail-closed 명시). */
154
+ export const AppActionAclSchema = z.object({
155
+ allow: z.string().array(),
156
+ });
157
+ export type AppActionAcl = z.infer<typeof AppActionAclSchema>;
158
+
159
+ export const AppAclContentSchema = z.object({
160
+ v: VersionSchema,
161
+ /** action_id → ACL. 없는 액션은 제한 없음. */
162
+ actions: z.record(z.string(), AppActionAclSchema),
163
+ });
164
+ export type AppAclContent = z.infer<typeof AppAclContentSchema>;
165
+
166
+ /** 앱의 ACL 상태 이벤트 타입 — `org.corp.<app>.acl`(앱 네임스페이스 소유 규칙과 동일). */
167
+ export function appAclEventType(app: string): string {
168
+ return `org.corp.${app}.acl`;
169
+ }
170
+
145
171
  // --------------------------------------------------------------------------
146
172
  // org.corp.workflow.state (상태 이벤트, last-writer-wins)
147
173
  // --------------------------------------------------------------------------
@@ -216,6 +242,96 @@ export function buildRoomApps(
216
242
  return RoomAppsContentSchema.parse({ ...roomApps, v: SCHEMA_VERSION });
217
243
  }
218
244
 
245
+ // --------------------------------------------------------------------------
246
+ // org.corp.note.* (상태 이벤트 — 채널 노트 = 다같이 보고 다같이 고치는 블록 문서)
247
+ // --------------------------------------------------------------------------
248
+ // 채널마다 노트 **여러 개**. 문서는 **블록 단위로 쪼개진 상태 이벤트**로 저장한다(namespace.ts NoteBlock 주석):
249
+ // org.corp.note.block state_key = <노트 id>.<블록 id> ← 동기화 단위. 삭제 = 빈 content
250
+ // org.corp.note.meta state_key = 노트 id ← 제목 + 목록 정렬 키. 삭제 = 빈 content
251
+ // org.corp.note.editing state_key = 편집 세션 ← "누가 어느 블록을 만지는 중" 표시(강제 잠금 아님)
252
+ //
253
+ // 노트 id 가 **state_key 안에** 사는 이유와 옛 문서(채널당 1개 시절, 구분자 없는 state_key)를 어떻게
254
+ // 이어받는지는 client-web 의 noteModel.ts `DEFAULT_NOTE_ID` 주석에 있다 — 판정은 거기 한 곳뿐이다.
255
+ //
256
+ // 순서는 배열이 아니라 블록마다 가진 **분수 인덱스 문자열**(`order`)이 정한다. 문자열 오름차순이 곧 표시
257
+ // 순서라, 사이에 끼워 넣어도 이웃 블록을 다시 쓰지 않는다(= 삽입이 남의 편집을 밟지 않는다).
258
+ // 누가/언제 고쳤는지는 상태 이벤트의 sender/origin_server_ts 가 이미 갖고 있어 content 에 복제하지 않는다.
259
+
260
+ /** 노트 블록의 닫힌 종류 집합 — 모르는 종류는 수신측이 문단으로 관용 처리한다. */
261
+ export const NOTE_BLOCK_TYPES = [
262
+ "paragraph",
263
+ "heading",
264
+ "bullet",
265
+ "numbered",
266
+ "todo",
267
+ "quote",
268
+ "code",
269
+ "divider",
270
+ ] as const;
271
+ export type NoteBlockType = (typeof NOTE_BLOCK_TYPES)[number];
272
+
273
+ export const NoteBlockContentSchema = z.object({
274
+ v: VersionSchema,
275
+ type: z.enum(NOTE_BLOCK_TYPES),
276
+ /** 본문 — 인라인 마크다운(**굵게**·`코드`·[링크]). divider 는 빈 문자열. 상한은 이벤트 크기 방어. */
277
+ text: z.string().max(8000).default(""),
278
+ /** 정렬 키(분수 인덱스). 문자열 오름차순 = 표시 순서. */
279
+ order: z.string().min(1).max(200),
280
+ /** 제목 단계 — type="heading" 일 때만 의미(그 외엔 무시). */
281
+ level: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
282
+ /** 체크 여부 — type="todo" 일 때만 의미. */
283
+ checked: z.boolean().optional(),
284
+ });
285
+ export type NoteBlockContent = z.infer<typeof NoteBlockContentSchema>;
286
+
287
+ /** 노트 블록 content 를 만든다 (org.corp.note.block, state_key = 블록 id). */
288
+ export function buildNoteBlock(
289
+ block: Omit<NoteBlockContent, "v" | "text"> & { v?: typeof SCHEMA_VERSION; text?: string },
290
+ ): NoteBlockContent {
291
+ return NoteBlockContentSchema.parse({ ...block, v: SCHEMA_VERSION });
292
+ }
293
+
294
+ export const NoteMetaContentSchema = z.object({
295
+ v: VersionSchema,
296
+ /** 노트 제목. 비어 있으면 클라이언트가 기본 이름으로 대체한다. */
297
+ title: z.string().max(200).default(""),
298
+ /**
299
+ * 노트 **목록**의 정렬 키(분수 인덱스) — 블록의 `order` 와 같은 방식·같은 이유다.
300
+ * 옛 노트엔 없다(그때는 채널당 하나였다) → 없으면 수신측이 노트 id 를 대신 쓴다.
301
+ */
302
+ order: z.string().min(1).max(200).optional(),
303
+ });
304
+ export type NoteMetaContent = z.infer<typeof NoteMetaContentSchema>;
305
+
306
+ /** 노트 메타 content 를 만든다 (org.corp.note.meta, state_key = 노트 id — 기본 노트는 ""). */
307
+ export function buildNoteMeta(
308
+ meta: Omit<NoteMetaContent, "v" | "title"> & { v?: typeof SCHEMA_VERSION; title?: string },
309
+ ): NoteMetaContent {
310
+ return NoteMetaContentSchema.parse({ ...meta, v: SCHEMA_VERSION });
311
+ }
312
+
313
+ export const NoteEditingContentSchema = z.object({
314
+ v: VersionSchema,
315
+ /** 지금 만지는 중인 블록 id. 편집을 멈추면 빈 content 로 지운다. */
316
+ block_id: z.string().min(1),
317
+ /**
318
+ * 발신 시각(ms, 발신자 시계). 수신측이 `Date.now() - ts > TTL` 로 스테일을 버린다.
319
+ *
320
+ * origin_server_ts 가 아니라 content 에 두는 이유가 둘: ① 편집이 길어질 때 보내는 갱신이
321
+ * **직전과 같은 content 면 서버가 무시할 수 있어** 표시가 조용히 굳는다 — ts 가 매번 달라 그걸 막는다.
322
+ * ② 수신측은 자기 시계(Date.now())로 비교하는데 서버 시계와 섞으면 기준이 두 개가 된다.
323
+ */
324
+ ts: z.number().int().nonnegative(),
325
+ });
326
+ export type NoteEditingContent = z.infer<typeof NoteEditingContentSchema>;
327
+
328
+ /** 편집중 표시 content 를 만든다 (org.corp.note.editing, state_key = 편집자 MXID). */
329
+ export function buildNoteEditing(
330
+ editing: Omit<NoteEditingContent, "v"> & { v?: typeof SCHEMA_VERSION },
331
+ ): NoteEditingContent {
332
+ return NoteEditingContentSchema.parse({ ...editing, v: SCHEMA_VERSION });
333
+ }
334
+
219
335
  // --------------------------------------------------------------------------
220
336
  // org.corp.entity.snapshot (상태 이벤트, state_key = "" — 엔티티 표시 필드 투영)
221
337
  // --------------------------------------------------------------------------
package/src/namespace.ts CHANGED
@@ -48,6 +48,22 @@ export const EventType = {
48
48
  SpaceApps: `${NS}.space.apps`,
49
49
  /** 상태 이벤트: 룸(채널)이 직접 선언한 App Service(앱) 키 목록 — space.apps 의 per-room 짝, 탭으로 노출 (state_key = "") */
50
50
  RoomApps: `${NS}.room.apps`,
51
+ /**
52
+ * 상태 이벤트: 채널 노트(공동 편집 문서)의 **블록 하나** (state_key = 블록 id).
53
+ *
54
+ * 문서 전체가 아니라 블록마다 이벤트를 쪼갠 이유: Matrix 상태 해석은 (type, state_key) 별로 독립이라
55
+ * **서로 다른 블록을 동시에 고쳐도 충돌하지 않는다**. 한 이벤트에 블록 배열을 담으면 읽고-고쳐-쓰기가
56
+ * 되어 동시 편집이 서로를 지운다(lost update). 같은 블록을 동시에 고친 경우만 last-writer-wins.
57
+ * 블록 삭제 = 빈 content(Matrix 의 상태 해제 관용).
58
+ */
59
+ NoteBlock: `${NS}.note.block`,
60
+ /** 상태 이벤트: 채널 노트의 메타(제목) (state_key = "") */
61
+ NoteMeta: `${NS}.note.meta`,
62
+ /**
63
+ * 상태 이벤트: "지금 이 사람이 이 블록을 편집 중" 표시 (state_key = 편집자 MXID).
64
+ * 강제 잠금이 아니라 **표시용**이고, 유효기간은 수신측이 `ts` 로 판정한다(스테일 자동 소멸).
65
+ */
66
+ NoteEditing: `${NS}.note.editing`,
51
67
  } as const;
52
68
 
53
69
  /** ERP 인바운드 이벤트 타입 접두사. 실제 타입은 `org.corp.erp.<entity>.<verb>` 로 만든다. */
package/src/registry.ts CHANGED
@@ -17,6 +17,9 @@ import {
17
17
  CommandContentSchema,
18
18
  EntitySnapshotContentSchema,
19
19
  ErpEventContentSchema,
20
+ NoteBlockContentSchema,
21
+ NoteEditingContentSchema,
22
+ NoteMetaContentSchema,
20
23
  RoomAppsContentSchema,
21
24
  RoomBindingContentSchema,
22
25
  TaskCreateContentSchema,
@@ -162,6 +165,27 @@ export const EVENT_REGISTRY: RegistryEntry[] = [
162
165
  publishableBy: ["human", "agent"],
163
166
  description: "작업항목 갱신 (상태/배정/진행) — 사람/에이전트가 발행, create 를 m.reference",
164
167
  },
168
+ {
169
+ type: EventType.NoteBlock,
170
+ kind: "state",
171
+ schema: NoteBlockContentSchema,
172
+ publishableBy: ["human", "agent"],
173
+ description: "채널 노트의 블록 하나 (state_key = 블록 id) — 블록 단위 동시 편집의 동기화 단위",
174
+ },
175
+ {
176
+ type: EventType.NoteMeta,
177
+ kind: "state",
178
+ schema: NoteMetaContentSchema,
179
+ publishableBy: ["human", "agent"],
180
+ description: "채널 노트의 메타(제목) (state_key = \"\")",
181
+ },
182
+ {
183
+ type: EventType.NoteEditing,
184
+ kind: "state",
185
+ schema: NoteEditingContentSchema,
186
+ publishableBy: ["human", "agent"],
187
+ description: "노트 편집중 표시 (state_key = 편집자 MXID) — 표시용, 수신측 TTL 로 소멸",
188
+ },
165
189
  {
166
190
  type: EventType.A2aRequest,
167
191
  kind: "message",
package/src/surfaces.ts CHANGED
@@ -9,7 +9,15 @@
9
9
  * 데이터 바인딩: 문자열 안의 `{{entity.path}}` 를 resolveBindings 로 룸 엔티티 스냅샷에서 채운다.
10
10
  */
11
11
  import { z } from "zod";
12
- import { ToneSchema } from "./blocks.js";
12
+ import {
13
+ CheckboxBlockSchema,
14
+ InputBlockSchema,
15
+ RadioBlockSchema,
16
+ SelectBlockSchema,
17
+ SliderBlockSchema,
18
+ ToggleBlockSchema,
19
+ ToneSchema,
20
+ } from "./blocks.js";
13
21
  import {
14
22
  AgentPlanContentSchema,
15
23
  AgentStepStatusSchema,
@@ -408,7 +416,29 @@ export const ModalConfirmSchema = z.object({
408
416
  });
409
417
  export type ModalConfirm = z.infer<typeof ModalConfirmSchema>;
410
418
 
411
- /** 모달/시트 스펙 — 제목 + 설명 + 톤 + 확인/취소. title/description 은 `{{entity.path}}` 바인딩 가능. */
419
+ /**
420
+ * 모달 폼 블록 — 카드 블록 어휘의 **입력 서브셋**만 허용한 닫힌 유니온(D1). 버튼/액션 블록은 넣지
421
+ * 않는다 — 모달의 실행 어포던스는 confirm 하나다(파괴적 액션 확인 게이트라는 정체성 유지).
422
+ * 렌더러(ModalRenderer)가 name 별 값을 수집해 confirm 발행 시 values 로 동봉한다.
423
+ */
424
+ export const ModalFormBlockSchema = z.discriminatedUnion("type", [
425
+ InputBlockSchema,
426
+ SelectBlockSchema,
427
+ RadioBlockSchema,
428
+ CheckboxBlockSchema,
429
+ ToggleBlockSchema,
430
+ SliderBlockSchema,
431
+ ]);
432
+ export type ModalFormBlock = z.infer<typeof ModalFormBlockSchema>;
433
+
434
+ /** 모달 폼 스텝(G3) — 제목 + 폼 블록 묶음. 카드 마법사(UiCardContent.steps)의 모달 짝. */
435
+ export const ModalStepSchema = z.object({
436
+ title: z.string().optional(),
437
+ blocks: ModalFormBlockSchema.array(),
438
+ });
439
+ export type ModalStep = z.infer<typeof ModalStepSchema>;
440
+
441
+ /** 모달/시트 스펙 — 제목 + 설명 + 톤 + (선택) 폼 블록 + 확인/취소. title/description 은 `{{entity.path}}` 바인딩 가능. */
412
442
  export const ModalSpecSchema = z.object({
413
443
  /** 이 모달의 안정적 id — 같은 id 의 셸 액션이 이 모달을 연다. */
414
444
  id: z.string(),
@@ -417,6 +447,20 @@ export const ModalSpecSchema = z.object({
417
447
  title: z.string(),
418
448
  description: z.string().optional(),
419
449
  tone: ToneSchema.optional(),
450
+ /**
451
+ * 폼 블록(선택, D1) — 확인 전에 사람이 채우는 입력들. confirm 발행 시 수집값이 values 로 동봉된다.
452
+ * 이 필드를 모르는 옛 클라는 zod strip 으로 조용히 떨궈 확인문구만 렌더한다(graceful degrade).
453
+ * 미래 블록 종류는 dropUnknownSurfaceSections 가 블록 단위로 걸러낸다(서피스 붕괴 방지).
454
+ */
455
+ blocks: ModalFormBlockSchema.array().optional(),
456
+ /**
457
+ * 스텝 폼(G3) — 있으면 렌더러가 마법사(이전/다음)로 그리고, 마지막 스텝에서만 confirm 을 활성한다.
458
+ * 이때 blocks 는 무시된다(전방호환 트릭: 발행 측 buildAppSurface 가 blocks 에 **평탄화 사본**을 자동
459
+ * 동봉하므로, steps 를 모르는 옛 클라는 strip 후 평면 폼(blocks)으로 렌더 — 기능 축소일 뿐 파손 없음).
460
+ */
461
+ steps: ModalStepSchema.array().optional(),
462
+ /** 자동 확인(리뷰) 스텝(G3) — steps 가 있을 때 마지막에 수집값 요약(라벨→값) 스텝을 붙인다. 기본 true. */
463
+ review: z.boolean().optional(),
420
464
  /** 확인 버튼(있으면) — 누르면 confirm.action_id 를 발행하고 닫는다. */
421
465
  confirm: ModalConfirmSchema.optional(),
422
466
  /** 취소/닫기 라벨(기본 "Cancel"). */
@@ -510,7 +554,13 @@ export type AppSurfaceContent = z.infer<typeof AppSurfaceContentSchema>;
510
554
  export function buildAppSurface(
511
555
  input: Omit<AppSurfaceContent, "v"> & { v?: typeof SCHEMA_VERSION },
512
556
  ): AppSurfaceContent {
513
- return AppSurfaceContentSchema.parse({ ...input, v: SCHEMA_VERSION });
557
+ // 스텝 모달(G3) 전방호환 — steps 선언 시 blocks 에 **평탄화 사본**을 자동 동봉한다(작성자가 blocks 를
558
+ // 직접 줬으면 존중). 신 클라는 steps 를 그리며 blocks 를 무시하고, steps 를 모르는 옛 클라는 zod strip
559
+ // 후 평면 폼(blocks)으로 렌더한다 — 기능 축소일 뿐 파손 없음.
560
+ const modals = input.modals?.map((m) =>
561
+ m.steps?.length && !m.blocks?.length ? { ...m, blocks: m.steps.flatMap((s) => s.blocks) } : m,
562
+ );
563
+ return AppSurfaceContentSchema.parse({ ...input, ...(modals ? { modals } : {}), v: SCHEMA_VERSION });
514
564
  }
515
565
 
516
566
  /**
@@ -529,10 +579,33 @@ export function dropUnknownSurfaceSections(raw: unknown): unknown {
529
579
  if (!Array.isArray(p.sections)) return panel;
530
580
  return { ...p, sections: p.sections.filter((s) => SurfaceSectionSchema.safeParse(s).success) };
531
581
  };
582
+ // 모달 폼 블록(D1)도 같은 자세 — blocks 는 닫힌 유니온이라 미래 블록 하나가 modals 배열 전체
583
+ // (나아가 서피스 전체)를 붕괴시킨다 → 블록 단위로 걸러 아는 블록 + 확인문구는 그린다.
584
+ // 스텝 폼(G3)의 steps[].blocks 도 동일 — 스텝 안 미래 블록 하나가 모달을 붕괴시키지 않게 거른다.
585
+ const keepModalBlocks = (modals: unknown): unknown => {
586
+ if (!Array.isArray(modals)) return modals;
587
+ const keepBlocks = (blocks: unknown): unknown =>
588
+ Array.isArray(blocks) ? blocks.filter((b) => ModalFormBlockSchema.safeParse(b).success) : blocks;
589
+ return modals.map((m) => {
590
+ if (!m || typeof m !== "object") return m;
591
+ const mm = m as Record<string, unknown>;
592
+ const out: Record<string, unknown> = { ...mm };
593
+ if (Array.isArray(mm.blocks)) out.blocks = keepBlocks(mm.blocks);
594
+ if (Array.isArray(mm.steps)) {
595
+ out.steps = mm.steps.map((s) => {
596
+ if (!s || typeof s !== "object") return s;
597
+ const ss = s as Record<string, unknown>;
598
+ return Array.isArray(ss.blocks) ? { ...ss, blocks: keepBlocks(ss.blocks) } : s;
599
+ });
600
+ }
601
+ return out;
602
+ });
603
+ };
532
604
  return {
533
605
  ...c,
534
606
  ...(c.panel !== undefined ? { panel: keepSections(c.panel) } : {}),
535
607
  ...(c.main !== undefined ? { main: keepSections(c.main) } : {}),
608
+ ...(c.modals !== undefined ? { modals: keepModalBlocks(c.modals) } : {}),
536
609
  };
537
610
  }
538
611
 
package/src/templates.ts CHANGED
@@ -373,7 +373,7 @@ function form(p: FormParams): UiCardContent {
373
373
  }
374
374
 
375
375
  // 도메인색이 강한 SCM(Forgejo) 템플릿(scmRef/prReview/ciRun)은 코어가 아니라 plugin-forgejo 가
376
- // 소유한다 — register() 에서 registerTemplate 로 등록(packages/plugin-forgejo/src/templates.ts).
376
+ // 소유한다 — register() 에서 registerTemplate 로 등록(packages/domain/plugin-forgejo/src/templates.ts).
377
377
  // 코어 templates.* 는 도메인 무관 프리미티브만 유지한다("도메인=플러그인" 가드레일).
378
378
 
379
379
  export interface ScorecardParams {