@aiquants/daily-report 0.12.3 → 0.13.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 (39) hide show
  1. package/README.md +32 -0
  2. package/dist/client.d.mts +1 -1
  3. package/dist/client.d.ts +1 -1
  4. package/dist/client.js +5 -5
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -5
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/server.d.mts +175 -21
  15. package/dist/server.d.ts +175 -21
  16. package/dist/server.js +12 -9
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +12 -9
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-YZh7Pz7i.d.ts → sse-schema-D1oZnFg6.d.ts} +18 -0
  21. package/dist/{sse-schema-BiUnufrI.d.mts → sse-schema-lMo8yW4e.d.mts} +18 -0
  22. package/package.json +1 -1
  23. package/src/client/components/daily-report-attachment-indicator.tsx +1 -1
  24. package/src/client/components/daily-report-detail-list.tsx +3 -3
  25. package/src/client/components/daily-report-ids-stream-status.tsx +1 -1
  26. package/src/client/components/daily-report-list.tsx +3 -3
  27. package/src/client/components/daily-report-resolved-content.tsx +3 -3
  28. package/src/lucide-icons.d.ts +3 -3
  29. package/src/server/handlers.ts +181 -35
  30. package/src/server/handlers.visibility.spec.ts +458 -0
  31. package/src/server/ports.ts +48 -0
  32. package/src/server/service.spec.ts +335 -14
  33. package/src/server/service.ts +222 -37
  34. package/src/server/test-helpers/handlers-config.ts +93 -9
  35. package/src/server/visibility.spec.ts +142 -0
  36. package/src/server/visibility.ts +189 -0
  37. package/src/server.spec.ts +72 -0
  38. package/src/server.ts +20 -1
  39. package/src/shared/sse-schema.ts +15 -0
@@ -9,13 +9,14 @@
9
9
  import { normalizeBusinessDateKey } from "../shared/business-date"
10
10
  import { type DailyReportIdsStreamChunkLine, type DailyReportIdsStreamCursor, findIdsStreamResumeIndex } from "../shared/ids-stream"
11
11
  import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
12
- import { dailyReportSseMessageSchema } from "../shared/sse-schema"
12
+ import { type DailyReportSseMessage, dailyReportSseMessageSchema } from "../shared/sse-schema"
13
13
  import type { DailyReportItem } from "../shared/types"
14
- import type { DailyReportAttachmentFailure, DailyReportAuthenticate, DailyReportEncodeUserId, DailyReportIdCodec, DailyReportReadAttachment, DailyReportRedisProvider } from "./ports"
14
+ import type { DailyReportAttachmentFailure, DailyReportAuthenticate, DailyReportEncodeUserId, DailyReportIdCodec, DailyReportReadAttachment, DailyReportRedisProvider, DailyReportResolveVisibleSourceTypes, DailyReportVisibilityResolveReason } from "./ports"
15
15
  import { jsonResponseWithETag } from "./response"
16
16
  import type { DailyReportService } from "./service"
17
17
  import type { DailyReportSseReader, StreamEntry } from "./sse-reader"
18
18
  import { isStreamIdLte } from "./sse-reader"
19
+ import { isSourceTypeVisible, normalizeVisibleSourceTypes, type VisibleSourceTypeSet } from "./visibility"
19
20
 
20
21
  type LoaderArgs = { request: Request; params: Record<string, string | undefined> }
21
22
 
@@ -65,6 +66,15 @@ const IDS_STREAM_HEARTBEAT_MS = 15_000
65
66
  /** next() 待ちがハートビート間隔に負けたことを表す番兵。 */
66
67
  const IDS_STREAM_HEARTBEAT = Symbol("ids-stream-heartbeat")
67
68
 
69
+ /**
70
+ * Keep-alive ticks between SSE visibility re-resolutions.
71
+ * SSE の可視集合を再解決する間隔 (keep-alive tick 数)。
72
+ *
73
+ * keep-alive は 5 秒間隔なので 12 tick = 約 60 秒。読み取り経路がリクエストごとに再解決するのに対し
74
+ * SSE は接続が長寿命なため、この間隔が権限失効の反映遅延の上限になる。
75
+ */
76
+ const SSE_VISIBILITY_REFRESH_TICKS = 12
77
+
68
78
  /**
69
79
  * Parses and validates resume-cursor query params (fail-closed).
70
80
  * 再開カーソルのクエリパラメータを検証付きで解釈する処理 (不正は fail-closed)。
@@ -490,9 +500,53 @@ const streamAndRelease = (bytes: Uint8Array<ArrayBuffer>, release: () => void, t
490
500
  })
491
501
  }
492
502
 
503
+ /**
504
+ * Decides whether an SSE message is visible under the viewer's source-type set.
505
+ * SSE メッセージが閲覧者の可視 sourceType 集合において可視かを判定する処理。
506
+ *
507
+ * - `connected` / `status-update`: 常に可視。前段で本人限定済みであり、区分も本文も載らない。
508
+ * - `report-create` / `report-update` / `report-publish`: 埋め込み `report.sourceType` で判定する。
509
+ * 値が欠落していれば `isSourceTypeVisible` の規則により制限閲覧者には不可視 (fail-close)。
510
+ * - `comment-add` / `comment-delete`: 新規コメントは `addComment` の Internal 限定ゲートを通るため
511
+ * 構造上 Internal のみ。明示 `sourceType` があればそれを使い、無い旧エントリは `"Internal"` として扱う。
512
+ * - `report-delete`: 明示 `sourceType` で判定し、値の無い旧エントリは fail-close で落とす
513
+ * (削除の取りこぼしは次回取得で自己修復する良性の劣化)。
514
+ *
515
+ * @param parsed Validated SSE message. 検証済みの SSE メッセージ。
516
+ * @param raw Raw payload carrying server-only fields. サーバー専用フィールドを含む生ペイロード。
517
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
518
+ * @returns True when the message may be delivered. 配信してよいなら true。
519
+ */
520
+ const isSseMessageSourceTypeVisible = (parsed: DailyReportSseMessage, raw: Record<string, unknown>, visible: VisibleSourceTypeSet): boolean => {
521
+ // 無制限なら種別を問わず配信する (現行互換の高速経路)
522
+ if (visible === null) {
523
+ return true
524
+ }
525
+ const rawSourceType = typeof raw.sourceType === "string" ? raw.sourceType : undefined
526
+ switch (parsed.type) {
527
+ case "connected":
528
+ case "status-update":
529
+ return true
530
+ case "report-create":
531
+ case "report-update":
532
+ case "report-publish":
533
+ return isSourceTypeVisible(visible, parsed.report.sourceType)
534
+ case "comment-add":
535
+ case "comment-delete":
536
+ return isSourceTypeVisible(visible, rawSourceType ?? "Internal")
537
+ case "report-delete":
538
+ return isSourceTypeVisible(visible, rawSourceType)
539
+ }
540
+ }
541
+
493
542
  export type DailyReportHandlersConfig = {
494
543
  /** リクエスト認証ポート。 */
495
544
  authenticate: DailyReportAuthenticate
545
+ /**
546
+ * 可視 sourceType リゾルバ。未注入なら全件 (現行互換)。
547
+ * 全データ経路 (一覧 / 詳細 / コメント / 添付 / SSE) に一貫して効き、例外は全拒否として扱う。
548
+ */
549
+ resolveVisibleSourceTypes?: DailyReportResolveVisibleSourceTypes
496
550
  /** データアクセスサービス。 */
497
551
  service: DailyReportService
498
552
  /** 内部数値 ID の難読化ポート。 */
@@ -537,6 +591,7 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
537
591
  const { authenticate, service, encodeUserId, redis, sseReader, streamKey } = config
538
592
  const loginRedirectPath = config.loginRedirectPath ?? "/auth/login"
539
593
  const apiLogger = config.logger ?? createLogger(LogLevel.ERROR, "[DailyReportAPI]")
594
+ const visibilityLogger = config.logger ?? createLogger(LogLevel.ERROR, "[DailyReportVisibility]")
540
595
  const sseLogger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportSSE]")
541
596
  const attachmentLogger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportAttachment]")
542
597
 
@@ -545,6 +600,30 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
545
600
  const consumeAttachmentRateToken = createRateLimiter(config.attachmentRateLimitPerMinute ?? DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE)
546
601
  const attachmentGate = createConcurrencyGate(config.attachmentConcurrency ?? DEFAULT_ATTACHMENT_CONCURRENCY)
547
602
 
603
+ /**
604
+ * Resolves and normalizes the visible source-type set once per request (fail-closed).
605
+ * リクエストごとに可視 sourceType 集合を 1 回だけ解決・正規化する処理 (fail-close)。
606
+ *
607
+ * ポート未注入なら `null` (無制限・現行互換)。ポートが例外を投げたら**空集合 (全拒否)**へ倒す。
608
+ * 認可ストアの障害時に無制限へ倒れないための唯一の防波堤であり、ここを緩めてはならない。
609
+ *
610
+ * @param request Incoming request. 受信したリクエスト。
611
+ * @param reason Why it is being resolved. 解決理由 (SSE の定期再解決は "refresh")。
612
+ * @returns Normalized visible set. 正規化済みの可視集合。
613
+ */
614
+ const resolveVisibleSet = async (request: Request, reason: DailyReportVisibilityResolveReason = "initial"): Promise<VisibleSourceTypeSet> => {
615
+ const port = config.resolveVisibleSourceTypes
616
+ if (!port) {
617
+ return null
618
+ }
619
+ try {
620
+ return normalizeVisibleSourceTypes(await port(request, { reason }))
621
+ } catch (e) {
622
+ visibilityLogger.error("resolveVisibleSourceTypes threw; failing closed (deny all)", e)
623
+ return new Set<string>()
624
+ }
625
+ }
626
+
548
627
  // ---------------- index (画面ルート) ----------------
549
628
 
550
629
  /**
@@ -571,7 +650,7 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
571
650
  // ---------------- api (:endpoint ルーター) ----------------
572
651
 
573
652
  type User = { id: string }
574
- type EndpointHandler = (url: URL, cookie: string | null, request: Request, user: User) => Promise<Response>
653
+ type EndpointHandler = (url: URL, cookie: string | null, request: Request, user: User, visible: VisibleSourceTypeSet) => Promise<Response>
575
654
 
576
655
  /**
577
656
  * Handlers for each API endpoint.
@@ -582,14 +661,14 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
582
661
  * Retrieves daily reports for a specific business date.
583
662
  * 指定された営業日の日報一覧を取得する。
584
663
  */
585
- "business-date": async (url, cookie, request, user) => {
664
+ "business-date": async (url, cookie, request, user, visible) => {
586
665
  const normalizedBusinessDate = normalizeBusinessDateKey(url.searchParams.get("businessDate"))
587
666
  const forceRefresh = url.searchParams.get("forceRefresh") === "true"
588
667
 
589
668
  if (!normalizedBusinessDate) {
590
669
  return jsonResponseWithETag(request, cookie, { error: { message: "Invalid business date" } }, 400)
591
670
  }
592
- const reports = await service.getDailyReportsByBusinessDateByExternalId(normalizedBusinessDate, user.id, { forceRefresh })
671
+ const reports = await service.getDailyReportsByBusinessDateByExternalId(normalizedBusinessDate, user.id, { forceRefresh }, visible)
593
672
  return jsonResponseWithETag(request, cookie, { businessDate: normalizedBusinessDate, reports }, 200)
594
673
  },
595
674
  /**
@@ -604,7 +683,7 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
604
683
  * 全体データ量に依存しない)。キャッシュヒット時は全量が即勝ちし、従来どおり
605
684
  * 確定済み配列のメモリスライス送出になる。
606
685
  */
607
- "ids-stream": async (url, cookie, request, user) => {
686
+ "ids-stream": async (url, cookie, request, user, visible) => {
608
687
  const parsed = parseIdsStreamCursor(url)
609
688
  if ("invalid" in parsed) {
610
689
  return jsonResponseWithETag(request, cookie, { error: { message: "Invalid cursor" } }, 400)
@@ -612,7 +691,7 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
612
691
  const forceRefresh = url.searchParams.get("forceRefresh") === "true"
613
692
 
614
693
  // 全量 (SqlResultCache / epoch / コアレッシング維持) は常に即時に走らせ始める
615
- const fullArrayPromise = service.getDailyReportIdsByExternalId(user.id, { forceRefresh })
694
+ const fullArrayPromise = service.getDailyReportIdsByExternalId(user.id, { forceRefresh }, visible)
616
695
  // 拒否はイテレーターの await 側で扱う (ここで未処理拒否警告を出さない)
617
696
  fullArrayPromise.catch(() => {})
618
697
 
@@ -632,7 +711,7 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
632
711
  // キャッシュヒット時もこの TOP 200 クエリは走って結果が捨てられる (二重クエリ)。
633
712
  // インデックス順の部分走査で ms 級と実測見積もりされており、ヒット判定のための
634
713
  // キャッシュ同期 peek API を足す複雑さに見合わないため意図的に許容する
635
- const firstPagePromise = service.getDailyReportIdsFirstPageByExternalId(user.id, IDS_STREAM_FIRST_CHUNK_SIZE)
714
+ const firstPagePromise = service.getDailyReportIdsFirstPageByExternalId(user.id, IDS_STREAM_FIRST_CHUNK_SIZE, visible)
636
715
  firstPagePromise.catch(() => {})
637
716
 
638
717
  const lineIterator = (async function* (): AsyncGenerator<DailyReportIdsStreamChunkLine> {
@@ -680,7 +759,7 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
680
759
  * Retrieves details for a specific daily report.
681
760
  * 指定された日報の詳細情報を取得する。
682
761
  */
683
- report: async (url, cookie, request, user) => {
762
+ report: async (url, cookie, request, user, visible) => {
684
763
  const param = url.searchParams.get("reportHubId")
685
764
  const forceRefresh = url.searchParams.get("forceRefresh") === "true"
686
765
  const parsedId = param ? Number.parseInt(param, 10) : NaN
@@ -688,7 +767,7 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
688
767
  if (!Number.isFinite(parsedId) || parsedId <= 0) {
689
768
  return jsonResponseWithETag(request, cookie, { error: { message: "Invalid reportHubId" } }, 400)
690
769
  }
691
- const detail = await service.getDailyReportDetailByIdByExternalId(parsedId, user.id, { snapshot: true, forceRefresh })
770
+ const detail = await service.getDailyReportDetailByIdByExternalId(parsedId, user.id, { snapshot: true, forceRefresh }, visible)
692
771
  if (!detail) {
693
772
  return jsonResponseWithETag(request, cookie, { error: { message: "Report not found" } }, 404)
694
773
  }
@@ -719,7 +798,9 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
719
798
  const startTime = Date.now()
720
799
  try {
721
800
  const url = new URL(request.url)
722
- return await handler(url, sanitizedCookie, request, user)
801
+ // 可視集合はリクエストごとに 1 回だけ解決する (キャッシュキーが戻り値に依存する)
802
+ const visible = await resolveVisibleSet(request)
803
+ return await handler(url, sanitizedCookie, request, user, visible)
723
804
  } catch (error) {
724
805
  const elapsed = Date.now() - startTime
725
806
  const err = error instanceof Error ? error : new Error(String(error))
@@ -749,6 +830,10 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
749
830
  return jsonData({ error: "User not found" }, { status: 404 })
750
831
  }
751
832
 
833
+ // 可視集合はリクエストごとに 1 回だけ解決し、他者の日報に作用する 4 種へ渡す
834
+ // (create / update / publish / delete / clearCache は所有者・開発者ゲートで別管理)
835
+ const visible = await resolveVisibleSet(request)
836
+
752
837
  const formData = await request.formData()
753
838
  const intent = formData.get("intent")
754
839
  const reportHubIdRaw = formData.get("reportHubId")
@@ -775,6 +860,37 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
775
860
  return jsonData({ error: "Invalid reportHubId" }, { status: 400 })
776
861
  }
777
862
 
863
+ /**
864
+ * Runs a boolean user-status toggle and maps its outcome onto a response.
865
+ * 真偽値のユーザー状態トグルを実行し、結果をレスポンスへ写像する処理。
866
+ *
867
+ * `toggleStar` と `toggleRead` は必須フィールド名と呼び出すサービスだけが異なるため、
868
+ * 検証・応答整形・エラー写像をここへ集約する。
869
+ *
870
+ * @param intentName Intent echoed back to the client. クライアントへ返す intent 名。
871
+ * @param fieldName Required boolean form field. 必須の真偽値フォームフィールド名。
872
+ * @param apply Service call performing the toggle. トグルを実行するサービス呼び出し。
873
+ * @returns The HTTP response. HTTP レスポンス。
874
+ */
875
+ const runStatusToggle = async (intentName: "toggleStar" | "toggleRead", fieldName: "isStarred" | "isRead", apply: (value: boolean) => Promise<{ isStarred: boolean; isRead: boolean }>): Promise<Response> => {
876
+ const raw = formData.get(fieldName)
877
+ if (raw === null) {
878
+ return jsonData({ error: `${fieldName} required` }, { status: 400 })
879
+ }
880
+ try {
881
+ const updatedStatus = await apply(raw === "true")
882
+ // クライアントは isStarred/isRead のみ参照する。生内部 ID (userId/created_by/updated_by) を
883
+ // 含む行全体は返さず、必要なフラグだけに絞る (内部 ID の自己開示防止)。
884
+ return jsonData({ status: "OK", intent: intentName, updatedStatus: { isStarred: updatedStatus.isStarred, isRead: updatedStatus.isRead }, operationTimestamp, reportHubId: String(reportHubId), clientTempId })
885
+ } catch (e: unknown) {
886
+ // 不可視区分・不存在・削除済みは 404 へ写像する (未捕捉だと 500 になる)
887
+ if (e instanceof Error && e.message === "Not Found") {
888
+ return jsonData({ error: "Report not found" }, { status: 404 })
889
+ }
890
+ throw e
891
+ }
892
+ }
893
+
778
894
  switch (intent) {
779
895
  case "create": {
780
896
  if (!businessDate) {
@@ -844,34 +960,17 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
844
960
  throw e
845
961
  }
846
962
  }
847
- case "toggleStar": {
848
- const isStarredRaw = formData.get("isStarred")
849
- if (isStarredRaw === null) {
850
- return jsonData({ error: "isStarred required" }, { status: 400 })
851
- }
852
- const isStarred = isStarredRaw === "true"
853
- const updatedStatus = await service.setStarStatus(userId, reportHubId, businessDate, isStarred, clientTempId)
854
- // クライアントは isStarred/isRead のみ参照する。生内部 ID (userId/created_by/updated_by) を
855
- // 含む行全体は返さず、必要なフラグだけに絞る (内部 ID の自己開示防止)。
856
- return jsonData({ status: "OK", intent: "toggleStar", updatedStatus: { isStarred: updatedStatus.isStarred, isRead: updatedStatus.isRead }, operationTimestamp, reportHubId: String(reportHubId), clientTempId })
857
- }
858
- case "toggleRead": {
859
- const isReadRaw = formData.get("isRead")
860
- if (isReadRaw === null) {
861
- return jsonData({ error: "isRead required" }, { status: 400 })
862
- }
863
- const isRead = isReadRaw === "true"
864
- const updatedStatus = await service.setReadStatus(userId, reportHubId, businessDate, isRead, clientTempId)
865
- // クライアントは isStarred/isRead のみ参照する (内部 ID の自己開示防止)。
866
- return jsonData({ status: "OK", intent: "toggleRead", updatedStatus: { isStarred: updatedStatus.isStarred, isRead: updatedStatus.isRead }, operationTimestamp, reportHubId: String(reportHubId), clientTempId })
867
- }
963
+ case "toggleStar":
964
+ return await runStatusToggle("toggleStar", "isStarred", (value) => service.setStarStatus(userId, reportHubId, businessDate, value, clientTempId, visible))
965
+ case "toggleRead":
966
+ return await runStatusToggle("toggleRead", "isRead", (value) => service.setReadStatus(userId, reportHubId, businessDate, value, clientTempId, visible))
868
967
  case "addComment": {
869
968
  const content = formData.get("content") as string
870
969
  if (!content) {
871
970
  return jsonData({ error: "Content required" }, { status: 400 })
872
971
  }
873
972
  try {
874
- const newComment = await service.addComment(userId, reportHubId, content, businessDate, clientTempId)
973
+ const newComment = await service.addComment(userId, reportHubId, content, businessDate, clientTempId, visible)
875
974
  const safeComment = { ...newComment, userId: newComment.userId }
876
975
  return jsonData({ status: "OK", intent: "addComment", newComment: safeComment, reportHubId: String(reportHubId), clientTempId })
877
976
  } catch (e: unknown) {
@@ -893,7 +992,7 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
893
992
  return jsonData({ error: "Invalid commentId" }, { status: 400 })
894
993
  }
895
994
  try {
896
- await service.deleteComment(userId, reportHubId, commentId, businessDate, clientTempId)
995
+ await service.deleteComment(userId, reportHubId, commentId, businessDate, clientTempId, visible)
897
996
  return jsonData({ status: "OK", reportHubId: String(reportHubId), deletedCommentId: String(commentId), clientTempId })
898
997
  } catch (e: unknown) {
899
998
  if (e instanceof Error) {
@@ -936,6 +1035,12 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
936
1035
  return new Response("Not Found", { status: 404 })
937
1036
  }
938
1037
 
1038
+ // 可視集合は接続時に解決し、以降 keep-alive tick に相乗りして再解決する (D6)。
1039
+ // 接続生存期間で固定すると、権限失効後も本文付きイベントが届き続けてしまう
1040
+ let visible = await resolveVisibleSet(request)
1041
+ let keepAliveTick = 0
1042
+ let resolveGeneration = 0
1043
+
939
1044
  // URL から lastEventId を取得 (再接続時の catch-up 用)
940
1045
  // クエリパラメータ優先、EventSource 自動再接続時の Last-Event-ID ヘッダーにフォールバック
941
1046
  const url = new URL(request.url)
@@ -984,6 +1089,22 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
984
1089
  * Processes a single Redis Stream entry and sends it to the SSE client.
985
1090
  * Redis Stream のエントリを処理し、SSE クライアントに送信する。
986
1091
  */
1092
+ /**
1093
+ * Emits a content-free frame that only advances the client's resume anchor.
1094
+ * 内容を伴わず、クライアントの再開アンカーだけを前進させるフレームを送出する処理。
1095
+ *
1096
+ * 種別は `connected` を使う。クライアントは既にこの種別を無視しつつ `lastEventId` を
1097
+ * 更新するため、フィルタで落としたエントリの ID を安全に伝播できる。
1098
+ *
1099
+ * @param entryId Redis Stream entry id. Redis Stream のエントリ ID。
1100
+ * @returns Nothing. なし。
1101
+ */
1102
+ const emitAnchorOnly = (entryId: string): void => {
1103
+ if (controllerRef && controllerRef.desiredSize !== null) {
1104
+ controllerRef.enqueue(encoder.encode(`id: ${entryId}\ndata: ${JSON.stringify({ type: "connected" })}\n\n`))
1105
+ }
1106
+ }
1107
+
987
1108
  const processEntry = (entryId: string, fields: Record<string, string>) => {
988
1109
  // data フィールドの存在チェック
989
1110
  if (!fields.data) return
@@ -1008,6 +1129,17 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
1008
1129
  return
1009
1130
  }
1010
1131
 
1132
+ // 区分の可視性フィルタ (宛先フィルタを通過した後に適用する)
1133
+ if (!isSseMessageSourceTypeVisible(parsed, raw, visible)) {
1134
+ // ❗ 内容は送らないが、再開アンカーだけは前へ進める。クライアントの
1135
+ // `lastEventId` は配信されたイベントでしか更新されないため、落とし続けると
1136
+ // アンカーが古いまま固定され、再接続のたびに同じ範囲を読み直す
1137
+ // (かつ catch-up の COUNT 上限を超えた分を取りこぼしうる)。
1138
+ // `connected` はクライアントが既に無視する種別なので、内容は漏れない
1139
+ emitAnchorOnly(entryId)
1140
+ return
1141
+ }
1142
+
1011
1143
  // recipientRawUserId をクライアントに送信しない (内部 ID 漏洩防止)
1012
1144
  let sanitizedMessage: string
1013
1145
  if ("recipientRawUserId" in raw) {
@@ -1036,6 +1168,18 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
1036
1168
  try {
1037
1169
  if (controllerRef && controllerRef.desiredSize !== null) {
1038
1170
  controllerRef.enqueue(encoder.encode(": keep-alive\n\n"))
1171
+ // 約 60 秒ごと (12 tick) に可視集合を再解決し、権限失効を有界化する。
1172
+ // 世代ガード: 順序が逆転した古い解決結果で最新を上書きしない
1173
+ // (上書きすると広い集合へ戻り fail-open 方向に倒れる)。
1174
+ // resolveVisibleSet 自身が fail-close なので catch は不要
1175
+ if (++keepAliveTick % SSE_VISIBILITY_REFRESH_TICKS === 0) {
1176
+ const generation = ++resolveGeneration
1177
+ resolveVisibleSet(request, "refresh").then((next) => {
1178
+ if (generation === resolveGeneration) {
1179
+ visible = next
1180
+ }
1181
+ })
1182
+ }
1039
1183
  } else {
1040
1184
  cleanup()
1041
1185
  }
@@ -1185,7 +1329,9 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
1185
1329
  }
1186
1330
 
1187
1331
  // 認可判定 (SQL 側で完結)。null は「存在しない」「認可されない」「親が論理削除済み」の合流
1188
- const attachment = await service.getAttachmentForUser(attachmentId, internalUserId)
1332
+ // 可視集合はこの try の内側で解決する (例外封じ込め規約の維持)
1333
+ const visible = await resolveVisibleSet(request)
1334
+ const attachment = await service.getAttachmentForUser(attachmentId, internalUserId, visible)
1189
1335
  if (!attachment) {
1190
1336
  attachmentLogger.error(`404 attachment=${attachmentId} viewer=${internalUserId} reason=not_visible`)
1191
1337
  return fail(404, "Attachment not found")