Compare commits

..
Author SHA1 Message Date
Jarvis c0539f4867 comms: homelab 20260726T165824Z 2026-07-26 11:58:24 -05:00
Jarvis e44f1bb7fb comms: homelab 20260726T164340Z 2026-07-26 11:43:40 -05:00
Jarvis 1ee71bea8b comms: homelab 20260726T162809Z 2026-07-26 11:28:09 -05:00
Jarvis 2be70d91ab comms: homelab 20260726T160150Z 2026-07-26 11:01:50 -05:00
Jarvis 8ffcc380f7 comms: homelab 20260726T154254Z 2026-07-26 10:42:54 -05:00
Jarvis 129f6c4051 comms: homelab 20260726T152805Z 2026-07-26 10:28:06 -05:00
Jarvis 142fbd22de comms: homelab 20260726T151235Z 2026-07-26 10:12:35 -05:00
Jarvis 07106b763b comms: homelab 20260726T145737Z 2026-07-26 09:57:37 -05:00
Jarvis d9c789e236 comms: homelab 20260726T142722Z 2026-07-26 09:27:22 -05:00
Jarvis 7e1f64128e comms: homelab 20260726T135725Z 2026-07-26 08:57:25 -05:00
Jarvis 3a5df0387e comms: homelab 20260726T134208Z 2026-07-26 08:42:08 -05:00
Jarvis 5d92d20af2 comms: homelab 20260726T132658Z 2026-07-26 08:26:58 -05:00
Jarvis 121ee6daee comms: homelab 20260726T131145Z 2026-07-26 08:11:45 -05:00
Jarvis fd8f8c37c1 comms: homelab 20260726T125638Z 2026-07-26 07:56:38 -05:00
Jarvis c41baaa3f9 comms: homelab 20260726T124137Z 2026-07-26 07:41:37 -05:00
Jarvis 5c1d72fa38 comms: homelab 20260726T122618Z 2026-07-26 07:26:18 -05:00
Jarvis 5a360a75b0 comms: homelab 20260726T115604Z 2026-07-26 06:56:04 -05:00
Jarvis 543ed721a3 comms: homelab 20260726T114051Z 2026-07-26 06:40:51 -05:00
Jarvis eafc455aef comms: homelab 20260726T111054Z 2026-07-26 06:10:54 -05:00
Jarvis 8919e4a899 comms: homelab 20260726T105532Z 2026-07-26 05:55:32 -05:00
Jarvis f02e4770db comms: homelab 20260726T104324Z 2026-07-26 05:43:24 -05:00
Jarvis 2e08eb3f2f comms: homelab 20260726T102505Z 2026-07-26 05:25:05 -05:00
Jarvis 8991aae330 comms: homelab 20260726T100951Z 2026-07-26 05:09:51 -05:00
Jarvis 1834f0e6c7 comms: homelab 20260726T090907Z 2026-07-26 04:09:07 -05:00
Jarvis 3ad65deeb1 comms: homelab 20260726T085420Z 2026-07-26 03:54:20 -05:00
Jarvis 96910e9eaf comms: homelab 20260726T083907Z 2026-07-26 03:39:07 -05:00
Jarvis 73760f1874 comms: homelab 20260726T082414Z 2026-07-26 03:24:14 -05:00
Jarvis 063614583a comms: homelab 20260726T080855Z 2026-07-26 03:08:55 -05:00
Jarvis b039e7f7f1 comms: homelab 20260726T075350Z 2026-07-26 02:53:50 -05:00
Jarvis 21c3042f74 comms: homelab 20260726T073840Z 2026-07-26 02:38:40 -05:00
Jarvis 74b391e642 comms: homelab 20260726T072351Z 2026-07-26 02:23:51 -05:00
Jarvis 6e7de21984 comms: homelab 20260726T070830Z 2026-07-26 02:08:30 -05:00
Jarvis d3467241bc comms: homelab 20260726T065341Z 2026-07-26 01:53:41 -05:00
Jarvis ee3fdaf8fb comms: homelab 20260726T063836Z 2026-07-26 01:38:36 -05:00
Jarvis c916e8ae79 comms: homelab 20260726T062332Z 2026-07-26 01:23:32 -05:00
Jarvis 15be5bd67e comms: homelab 20260726T061138Z 2026-07-26 01:11:38 -05:00
Jarvis af9ffca83a comms: homelab 20260726T055254Z 2026-07-26 00:52:54 -05:00
Jarvis 44dea90d3f comms: homelab 20260726T053742Z 2026-07-26 00:37:42 -05:00
Jarvis 506751decb comms: homelab 20260726T052239Z 2026-07-26 00:22:39 -05:00
Jarvis 6b330df612 comms: homelab 20260726T043720Z 2026-07-25 23:37:20 -05:00
Jarvis 5c39e133c2 comms: homelab 20260726T042222Z 2026-07-25 23:22:22 -05:00
Jarvis 31364a0e3a comms: homelab 20260726T040703Z 2026-07-25 23:07:03 -05:00
Jarvis d92f4f1193 comms: homelab 20260726T035152Z 2026-07-25 22:51:52 -05:00
Jarvis e01f24dd10 comms: homelab 20260726T033639Z 2026-07-25 22:36:39 -05:00
Jarvis 5bbb7c6c5c comms: homelab 20260726T032137Z 2026-07-25 22:21:37 -05:00
wjarvis mos-comms ca24e19443 comms: usc 20260726T025240Z 2026-07-25 21:52:41 -05:00
Jarvis 3345690710 comms: homelab 20260726T025124Z 2026-07-25 21:51:24 -05:00
Jarvis 1b90dc33e4 comms: homelab 20260726T023642Z 2026-07-25 21:36:42 -05:00
Jarvis ca578af5b2 comms: homelab 20260726T022123Z 2026-07-25 21:21:23 -05:00
Jarvis e4f863cebc comms: homelab 20260726T020635Z 2026-07-25 21:06:35 -05:00
Jarvis c2596fc5e1 comms: homelab 20260726T013951Z 2026-07-25 20:39:51 -05:00
Jarvis 4336aafa97 comms: homelab 20260726T012123Z 2026-07-25 20:21:23 -05:00
Jarvis 597d39d76e comms: homelab 20260726T010648Z 2026-07-25 20:06:49 -05:00
Jarvis 2d9cf0e4d1 comms: homelab 20260726T005133Z 2026-07-25 19:51:33 -05:00
Jarvis 5a864a5267 comms: homelab 20260726T003618Z 2026-07-25 19:36:18 -05:00
Jarvis 6a967068f8 comms: homelab 20260726T002043Z 2026-07-25 19:20:43 -05:00
Jarvis e1d14fd76c comms: homelab 20260726T000936Z 2026-07-25 19:09:37 -05:00
wjarvis mos-comms 89b43a33b7 comms: usc 20260726T000747Z 2026-07-25 19:07:48 -05:00
Jarvis 23b036d169 comms: homelab 20260726T000606Z 2026-07-25 19:06:06 -05:00
Jarvis 7c2281ae83 comms: homelab 20260725T235047Z 2026-07-25 18:50:47 -05:00
Jarvis e1434e3e99 comms: homelab 20260725T233547Z 2026-07-25 18:35:47 -05:00
Jarvis 180abbb35c comms: homelab 20260725T232042Z 2026-07-25 18:20:42 -05:00
Jarvis 7362d4c7c9 comms: homelab 20260725T230615Z 2026-07-25 18:06:15 -05:00
Jarvis 6dedf0df40 comms: homelab 20260725T225022Z 2026-07-25 17:50:22 -05:00
Jarvis e1ad3a3a66 comms: homelab 20260725T223512Z 2026-07-25 17:35:12 -05:00
Jarvis f2fc66f652 comms: homelab 20260725T222016Z 2026-07-25 17:20:17 -05:00
Jarvis ee6687d8b9 comms: homelab 20260725T220506Z 2026-07-25 17:05:06 -05:00
Jarvis c9d147f28f comms: homelab 20260725T215002Z 2026-07-25 16:50:02 -05:00
Jarvis f86c4ca16d comms: homelab 20260725T213453Z 2026-07-25 16:34:53 -05:00
Jarvis 2ab267c117 comms: homelab 20260725T212013Z 2026-07-25 16:20:13 -05:00
Jarvis 4998d1d9df comms: homelab 20260725T210552Z 2026-07-25 16:05:52 -05:00
wjarvis mos-comms 0265f1950c comms: usc 20260725T204825Z 2026-07-25 15:48:25 -05:00
wjarvis mos-comms 0475ae2b19 comms: usc 20260725T200924Z 2026-07-25 15:09:24 -05:00
wjarvis mos-comms 713bbaa060 comms: usc 20260725T183232Z 2026-07-25 13:32:33 -05:00
Jarvis 619ed38d01 comms: homelab 20260725T183033Z 2026-07-25 13:30:33 -05:00
wjarvis mos-comms 2adae48830 comms: usc 20260725T182546Z 2026-07-25 13:25:46 -05:00
ms-lead-reviewer b4c2af0969 briefs: heartbeat PACKAGING PLAN Rev 3 — add W-jarvis Gate A (manifest metadata-only, single ownership SSOT) + Gate B (schema range compat) 2026-07-25 13:25:34 -05:00
Jarvis 8a72f5f9f4 comms: homelab 20260725T182226Z 2026-07-25 13:22:27 -05:00
wjarvis mos-comms 89e59f9353 comms: usc 20260725T182026Z 2026-07-25 13:20:26 -05:00
ms-lead-reviewer c043e025a6 briefs: heartbeat-canon PACKAGING PLAN Rev 2 — address W-jarvis review (manifest/version, schema ordering, blank-reset target, fw/op boundaries) 2026-07-25 13:20:10 -05:00
Jarvis 55b4038283 comms: homelab 20260725T181626Z 2026-07-25 13:16:26 -05:00
wjarvis mos-comms bdad50cffd comms: usc 20260725T180916Z 2026-07-25 13:09:16 -05:00
ms-lead-reviewer cd11bbbc52 briefs: heartbeat-canon framework PACKAGING PLAN (MS-LEAD, item-5) — PROPOSED, pending Mos authority 2026-07-25 13:09:02 -05:00
Jarvis 44c05ae0a5 comms: homelab 20260725T180535Z 2026-07-25 13:05:35 -05:00
wjarvis mos-comms 29e98d6da5 comms: usc 20260725T180407Z 2026-07-25 13:04:07 -05:00
Jarvis c44325dcc4 comms: homelab 20260725T175300Z 2026-07-25 12:53:00 -05:00
wjarvis mos-comms a9d4f0c8e2 comms: usc 20260725T175123Z 2026-07-25 12:51:24 -05:00
Jarvis b99797614b comms: homelab 20260725T174829Z 2026-07-25 12:48:29 -05:00
Jarvis 15589cf9a5 comms: homelab 20260725T174638Z 2026-07-25 12:46:38 -05:00
wjarvis mos-comms 4c4f2fe265 comms: usc 20260725T174445Z 2026-07-25 12:44:46 -05:00
Jarvis ff78acc389 comms: homelab 20260725T174119Z 2026-07-25 12:41:20 -05:00
wjarvis mos-comms ed9039a760 comms: usc 20260725T173939Z 2026-07-25 12:39:39 -05:00
Jarvis 4d3e0abd80 comms: homelab 20260725T173738Z 2026-07-25 12:37:39 -05:00
wjarvis mos-comms 3458ae1f0b comms: usc 20260725T173627Z 2026-07-25 12:36:28 -05:00
Jarvis 91c51c93cc comms: homelab 20260725T173325Z 2026-07-25 12:33:26 -05:00
Jarvis bfaf2c2266 comms: homelab 20260725T173201Z 2026-07-25 12:32:01 -05:00
wjarvis mos-comms 3c4e9ace61 comms: usc 20260725T173015Z 2026-07-25 12:30:15 -05:00
Jarvis e96c2ba2de comms: homelab 20260725T173005Z 2026-07-25 12:30:06 -05:00
wjarvis mos-comms 40c836e5b8 comms: usc 20260725T172849Z 2026-07-25 12:28:50 -05:00
Jarvis 4046e2e8d2 comms: homelab 20260725T172436Z 2026-07-25 12:24:36 -05:00
wjarvis mos-comms 85fa0d48d5 comms: usc 20260725T172224Z 2026-07-25 12:22:25 -05:00
Jarvis 094709ca8b comms: homelab 20260725T013224Z 2026-07-24 20:32:24 -05:00
wjarvis mos-comms f0560004c5 comms: usc 20260725T013124Z 2026-07-24 20:31:25 -05:00
Jarvis bd467d6925 comms: homelab 20260725T012643Z 2026-07-24 20:26:43 -05:00
Jarvis d3a3eba636 comms: homelab 20260725T010740Z 2026-07-24 20:07:40 -05:00
wjarvis mos-comms 9883cd6f31 comms: usc 20260725T010612Z 2026-07-24 20:06:17 -05:00
Jarvis 4847d35f31 comms: homelab 20260725T010445Z 2026-07-24 20:04:45 -05:00
wjarvis mos-comms 024d0f00c5 comms: usc 20260725T010346Z 2026-07-24 20:03:52 -05:00
Jarvis 131e8d5d78 comms: homelab 20260725T005606Z 2026-07-24 19:56:06 -05:00
Jarvis 46453c475b comms: homelab 20260725T004609Z 2026-07-24 19:46:09 -05:00
wjarvis mos-comms 7954fb9b0b comms: usc 20260725T004445Z 2026-07-24 19:44:45 -05:00
Jarvis 5f3ab1c25c comms: homelab 20260725T004231Z 2026-07-24 19:42:32 -05:00
wjarvis mos-comms a7d3cb0dbf comms: usc 20260725T004156Z 2026-07-24 19:41:57 -05:00
Jarvis 8e1b229f0f comms: homelab 20260725T004106Z 2026-07-24 19:41:06 -05:00
Jarvis 8a47fb2179 comms: homelab 20260725T003706Z 2026-07-24 19:37:06 -05:00
wjarvis mos-comms 192c7204ad comms: usc 20260725T003526Z 2026-07-24 19:35:26 -05:00
Jarvis 53b90c0ffc comms: homelab 20260724T235833Z 2026-07-24 18:58:33 -05:00
wjarvis mos-comms 23800842a4 comms: usc 20260724T235736Z 2026-07-24 18:57:36 -05:00
Jarvis 0c1f1d84b0 comms: homelab 20260724T234845Z 2026-07-24 18:48:45 -05:00
wjarvis mos-comms 132a3fd969 comms: usc 20260724T234758Z 2026-07-24 18:47:58 -05:00
wjarvis mos-comms 3f93758b81 comms: usc 20260724T032741Z 2026-07-23 22:27:41 -05:00
wjarvis mos-comms 3c489ee778 comms: usc 20260724T002527Z 2026-07-23 19:25:27 -05:00
Jarvis 26b60a1a6c comms: homelab 20260723T235040Z 2026-07-23 18:50:40 -05:00
Jarvis 3df4ccd7c5 comms: homelab 20260723T235039Z 2026-07-23 18:50:39 -05:00
wjarvis mos-comms 9ca26d7988 comms: usc 20260723T234917Z 2026-07-23 18:49:17 -05:00
Jarvis 92e6f18ee3 comms: homelab 20260723T230555Z 2026-07-23 18:05:56 -05:00
wjarvis mos-comms d85565892d comms: usc 20260723T230446Z 2026-07-23 18:04:46 -05:00
Jarvis 22606c930f comms: homelab 20260723T224420Z 2026-07-23 17:44:21 -05:00
wjarvis mos-comms 330dbcd366 comms: usc 20260723T224305Z 2026-07-23 17:43:06 -05:00
wjarvis mos-comms 8257f2a8ec comms: usc 20260723T223319Z 2026-07-23 17:33:19 -05:00
wjarvis mos-comms f74831624d comms: usc 20260723T223240Z 2026-07-23 17:32:40 -05:00
Jarvis 01161e3674 comms: homelab 20260723T222915Z 2026-07-23 17:29:15 -05:00
Jarvis 2248463b7e comms: homelab 20260723T222913Z 2026-07-23 17:29:13 -05:00
Jarvis ce02c26641 comms: homelab 20260723T222437Z 2026-07-23 17:24:37 -05:00
Jarvis 3046c7e2ef comms: homelab 20260723T222435Z 2026-07-23 17:24:35 -05:00
wjarvis mos-comms d3a00c8d5f comms: usc 20260723T222323Z 2026-07-23 17:23:23 -05:00
wjarvis mos-comms b4fc939472 comms: usc 20260723T210042Z 2026-07-23 16:00:42 -05:00
wjarvis mos-comms 4153d962f9 comms: usc 20260723T205919Z 2026-07-23 15:59:19 -05:00
wjarvis mos-comms 8eebbc8902 comms: usc 20260723T203119Z 2026-07-23 15:31:19 -05:00
wjarvis mos-comms e3ac6a940d comms: usc 20260723T195550Z 2026-07-23 14:55:50 -05:00
Jarvis 7438fcc102 comms: homelab 20260723T195310Z 2026-07-23 14:53:10 -05:00
wjarvis mos-comms 2a6c50c6b8 comms: usc 20260723T195124Z 2026-07-23 14:51:24 -05:00
wjarvis mos-comms 853a57c47d comms: usc 20260723T194928Z 2026-07-23 14:49:28 -05:00
Jarvis 985a13fe4b comms: homelab 20260723T194321Z 2026-07-23 14:43:21 -05:00
Jarvis 6f9afca163 comms: homelab 20260723T194319Z 2026-07-23 14:43:20 -05:00
Jarvis 925d8f485e comms: homelab 20260723T191337Z 2026-07-23 14:13:37 -05:00
Jarvis 1ab215d25d comms: homelab 20260723T191336Z 2026-07-23 14:13:36 -05:00
Jarvis e7035e32eb comms: homelab 20260723T185849Z 2026-07-23 13:58:49 -05:00
Jarvis 17ec91dec4 comms: homelab 20260723T185839Z 2026-07-23 13:58:40 -05:00
Jarvis b603a1c608 comms: homelab 20260723T184313Z 2026-07-23 13:43:13 -05:00
Jarvis b2d6afb857 comms: homelab 20260723T184303Z 2026-07-23 13:43:03 -05:00
Jarvis 01cacefd81 comms: homelab 20260723T182831Z 2026-07-23 13:28:31 -05:00
Jarvis c7e40e2722 comms: homelab 20260723T182819Z 2026-07-23 13:28:19 -05:00
Jarvis 44ec1ee4ba comms: homelab 20260723T181303Z 2026-07-23 13:13:03 -05:00
Jarvis 9ed47e0d0c comms: homelab 20260723T181256Z 2026-07-23 13:12:56 -05:00
Jarvis f75e474de3 comms: homelab 20260723T175812Z 2026-07-23 12:58:12 -05:00
Jarvis 287e25393e comms: homelab 20260723T175803Z 2026-07-23 12:58:03 -05:00
Jarvis 95f9df120b comms: homelab 20260723T174338Z 2026-07-23 12:43:38 -05:00
Jarvis 7a5fc174e7 comms: homelab 20260723T174330Z 2026-07-23 12:43:30 -05:00
Jarvis 711d57ba70 comms: homelab 20260723T172740Z 2026-07-23 12:27:40 -05:00
Jarvis fd0f0c38d5 comms: homelab 20260723T172730Z 2026-07-23 12:27:30 -05:00
Jarvis 1f3df1e719 comms: homelab 20260723T171658Z 2026-07-23 12:16:58 -05:00
wjarvis mos-comms 654fa6d11e comms: usc 20260723T171508Z 2026-07-23 12:15:08 -05:00
Jarvis 8d1039a431 comms: homelab 20260723T171400Z 2026-07-23 12:14:00 -05:00
Jarvis 23b4bb700b comms: homelab 20260723T171349Z 2026-07-23 12:13:49 -05:00
Jarvis ad181d72e4 comms: homelab 20260723T165820Z 2026-07-23 11:58:20 -05:00
Jarvis 7875b71806 comms: homelab 20260723T165801Z 2026-07-23 11:58:01 -05:00
Jarvis eb09c01d85 comms: homelab 20260722T194433Z 2026-07-22 14:44:33 -05:00
Jarvis 25f7c8101b comms: homelab 20260722T193005Z 2026-07-22 14:30:06 -05:00
wjarvis mos-comms 1b6d6ffad8 comms: usc 20260722T191736Z 2026-07-22 14:17:36 -05:00
Jarvis 65d219350f comms: homelab 20260722T191432Z 2026-07-22 14:14:32 -05:00
wjarvis mos-comms 41a379c381 comms: usc 20260722T190150Z 2026-07-22 14:01:50 -05:00
Jarvis 23bd7e57d6 comms: homelab 20260722T190059Z 2026-07-22 14:00:59 -05:00
wjarvis mos-comms 4fa2ca6112 comms: usc 20260722T184813Z 2026-07-22 13:48:14 -05:00
Jarvis 7f79a3ad59 comms: homelab 20260722T184617Z 2026-07-22 13:46:17 -05:00
Jarvis 94eed819f5 comms: homelab 20260722T184543Z 2026-07-22 13:45:43 -05:00
wjarvis mos-comms 74a655f6f8 comms: usc 20260722T170420Z 2026-07-22 12:04:21 -05:00
wjarvis mos-comms a96a333c01 comms: usc 20260722T084427Z 2026-07-22 03:44:27 -05:00
Jarvis 8546fd4f04 comms: homelab 20260722T084137Z 2026-07-22 03:41:37 -05:00
wjarvis mos-comms 3b6278e09d comms: usc 20260722T081132Z 2026-07-22 03:11:33 -05:00
Jarvis 8176af89a4 comms: homelab 20260722T080237Z 2026-07-22 03:02:37 -05:00
wjarvis mos-comms 59a313cbe4 comms: usc 20260722T080151Z 2026-07-22 03:01:52 -05:00
Jarvis 0aa8cac23d comms: homelab 20260722T075800Z 2026-07-22 02:58:00 -05:00
wjarvis mos-comms a9d7e474f9 comms: usc 20260722T072817Z 2026-07-22 02:28:18 -05:00
Jarvis 8ac9a5f681 comms: homelab 20260722T065247Z 2026-07-22 01:52:48 -05:00
wjarvis mos-comms 2b364c8f1a comms: usc 20260722T065059Z 2026-07-22 01:50:59 -05:00
Jarvis 284f74d1e7 comms: homelab 20260722T064810Z 2026-07-22 01:48:10 -05:00
Jarvis 7e5d36902e comms: homelab 20260722T062936Z 2026-07-22 01:29:37 -05:00
wjarvis mos-comms f996bd5208 comms: usc 20260722T062605Z 2026-07-22 01:26:05 -05:00
wjarvis mos-comms 527f2ca7e3 comms: usc 20260722T060123Z 2026-07-22 01:01:24 -05:00
Jarvis eebedad810 comms: homelab 20260722T055755Z 2026-07-22 00:57:55 -05:00
Jarvis f7b42e1667 comms: homelab 20260722T054706Z 2026-07-22 00:47:07 -05:00
wjarvis mos-comms 57f3facaaa comms: usc 20260722T054305Z 2026-07-22 00:43:05 -05:00
wjarvis mos-comms 79e7efee47 comms: usc 20260722T052506Z 2026-07-22 00:25:06 -05:00
Jarvis 1a6f79b9cf comms: homelab 20260722T052113Z 2026-07-22 00:21:13 -05:00
Jarvis 17c3c84302 comms: homelab 20260722T051543Z 2026-07-22 00:15:43 -05:00
Jarvis 9edb16456a comms: homelab 20260722T051121Z 2026-07-22 00:11:22 -05:00
wjarvis mos-comms 62c967bb4f comms: usc 20260722T050835Z 2026-07-22 00:08:35 -05:00
wjarvis mos-comms 19fc2aaa83 comms: usc 20260722T041922Z 2026-07-21 23:19:22 -05:00
Jarvis 25e3438a15 comms: homelab 20260722T041710Z 2026-07-21 23:17:10 -05:00
Jarvis 6ffe38369e comms: homelab 20260722T040703Z 2026-07-21 23:07:04 -05:00
wjarvis mos-comms a73cb190f2 comms: usc 20260722T040433Z 2026-07-21 23:04:33 -05:00
wjarvis mos-comms 9757c9024a comms: usc 20260722T033246Z 2026-07-21 22:32:46 -05:00
wjarvis mos-comms 267436c4c4 comms: usc 20260722T033204Z 2026-07-21 22:32:05 -05:00
Jarvis d6fcb2dbbf comms: homelab 20260722T033125Z 2026-07-21 22:31:25 -05:00
Jarvis 0b0e2faf86 comms: homelab 20260722T032750Z 2026-07-21 22:27:50 -05:00
wjarvis mos-comms c60ea6b380 comms: usc 20260722T032435Z 2026-07-21 22:24:36 -05:00
Jarvis 3b2fdad204 comms: homelab 20260722T032230Z 2026-07-21 22:22:30 -05:00
wjarvis mos-comms 203a94d90c comms: usc 20260722T030500Z 2026-07-21 22:05:00 -05:00
wjarvis mos-comms 6412522c63 comms: usc 20260722T025318Z 2026-07-21 21:53:18 -05:00
wjarvis mos-comms 337de41157 comms: usc 20260722T023242Z 2026-07-21 21:32:42 -05:00
Jarvis f428eff373 comms: homelab 20260722T023043Z 2026-07-21 21:30:43 -05:00
wjarvis mos-comms ca6d3d6cc0 comms: usc 20260722T015336Z 2026-07-21 20:53:36 -05:00
wjarvis mos-comms ec7dd41448 comms: usc 20260722T012734Z 2026-07-21 20:27:34 -05:00
Jarvis ad52e7c4bb comms: homelab 20260722T012617Z 2026-07-21 20:26:18 -05:00
wjarvis mos-comms b44f5e0d88 comms: usc 20260722T012059Z 2026-07-21 20:20:59 -05:00
wjarvis mos-comms 29789df372 comms: usc 20260722T011048Z 2026-07-21 20:10:48 -05:00
wjarvis mos-comms 2e0171a205 comms: usc 20260722T005605Z 2026-07-21 19:56:05 -05:00
wjarvis mos-comms 5603e5f41a comms: usc 20260722T005042Z 2026-07-21 19:50:42 -05:00
wjarvis mos-comms cdf6b02614 comms: usc 20260722T001551Z 2026-07-21 19:15:51 -05:00
Jarvis 09de0904ca comms: homelab 20260722T001353Z 2026-07-21 19:13:53 -05:00
wjarvis mos-comms 807b369b9b comms: usc 20260722T000540Z 2026-07-21 19:05:40 -05:00
wjarvis mos-comms f3c6817ccb comms: usc 20260721T235939Z 2026-07-21 18:59:39 -05:00
wjarvis mos-comms f74bd8e677 comms: usc 20260721T235935Z 2026-07-21 18:59:35 -05:00
Jarvis 408764c237 comms: homelab 20260721T235545Z 2026-07-21 18:55:45 -05:00
wjarvis mos-comms 3a3adb6b71 comms: usc 20260721T235452Z 2026-07-21 18:54:52 -05:00
wjarvis mos-comms dff17525c9 comms: usc 20260721T235415Z 2026-07-21 18:54:16 -05:00
Jarvis 0993cf67e9 comms: homelab 20260721T235305Z 2026-07-21 18:53:06 -05:00
wjarvis mos-comms d33d6ec911 comms: usc 20260721T235118Z 2026-07-21 18:51:18 -05:00
wjarvis mos-comms cb1775cf56 comms: usc 20260721T235005Z 2026-07-21 18:50:06 -05:00
Jarvis 245c1019e1 comms: homelab 20260721T234835Z 2026-07-21 18:48:36 -05:00
wjarvis mos-comms caed94e522 comms: usc 20260721T234732Z 2026-07-21 18:47:32 -05:00
wjarvis mos-comms 7aed068f68 comms: usc 20260721T232306Z 2026-07-21 18:23:06 -05:00
wjarvis mos-comms 64a300e68a comms: usc 20260721T231337Z 2026-07-21 18:13:37 -05:00
Jarvis a647bce678 comms: homelab 20260721T231053Z 2026-07-21 18:10:53 -05:00
wjarvis mos-comms cfa5810e59 comms: usc 20260721T223214Z 2026-07-21 17:32:14 -05:00
wjarvis mos-comms 5f204934de comms: usc 20260721T220716Z 2026-07-21 17:07:16 -05:00
Jarvis 802ef7f033 comms: homelab 20260721T220621Z 2026-07-21 17:06:21 -05:00
wjarvis mos-comms c2c25f45b5 comms: usc 20260721T220515Z 2026-07-21 17:05:15 -05:00
Jarvis 09e167fc0c comms: homelab 20260721T220356Z 2026-07-21 17:03:56 -05:00
Jarvis e09887427d comms: homelab 20260721T215824Z 2026-07-21 16:58:24 -05:00
wjarvis mos-comms 7251b1cced comms: usc 20260721T215708Z 2026-07-21 16:57:08 -05:00
wjarvis mos-comms f93c14b646 comms: usc 20260721T215706Z 2026-07-21 16:57:06 -05:00
Jarvis 4afa572aef comms: homelab 20260721T215452Z 2026-07-21 16:54:52 -05:00
wjarvis mos-comms 68d672b631 comms: usc 20260721T214857Z 2026-07-21 16:48:57 -05:00
Jarvis e386525d19 comms: homelab 20260721T214713Z 2026-07-21 16:47:13 -05:00
Jarvis b720270505 comms: homelab 20260721T214219Z 2026-07-21 16:42:19 -05:00
wjarvis mos-comms ed752a08d5 comms: usc 20260721T213915Z 2026-07-21 16:39:33 -05:00
Jarvis f52e721f29 comms: homelab 20260721T213759Z 2026-07-21 16:38:00 -05:00
wjarvis mos-comms a7173d1c33 comms: usc 20260721T213558Z 2026-07-21 16:35:58 -05:00
wjarvis mos-comms e32b5ef561 comms: usc 20260721T213030Z 2026-07-21 16:30:30 -05:00
wjarvis mos-comms 9070c9d6b0 comms: usc 20260721T201551Z 2026-07-21 15:15:51 -05:00
wjarvis mos-comms cf8ec13e57 comms: usc 20260721T191940Z 2026-07-21 14:19:40 -05:00
wjarvis mos-comms e07675ae07 comms: usc 20260721T191935Z 2026-07-21 14:19:36 -05:00
Jarvis 682fc820ee comms: homelab 20260721T191639Z 2026-07-21 14:16:40 -05:00
wjarvis mos-comms b9f5c9f679 comms: usc 20260721T184546Z 2026-07-21 13:45:46 -05:00
Jarvis 93f012bdcf comms: homelab 20260721T184447Z 2026-07-21 13:44:48 -05:00
wjarvis mos-comms 40e5b8bbd8 comms: usc 20260721T183050Z 2026-07-21 13:30:50 -05:00
Jarvis df6a24e0b8 comms: homelab 20260721T182845Z 2026-07-21 13:28:45 -05:00
wjarvis mos-comms 2ae6d1f1bc comms: usc 20260721T182443Z 2026-07-21 13:24:43 -05:00
Jarvis d08106bd35 comms: homelab 20260721T175405Z 2026-07-21 12:54:05 -05:00
wjarvis mos-comms 52830a684c comms: usc 20260721T175245Z 2026-07-21 12:52:45 -05:00
Jarvis d4b16b004e comms: homelab 20260721T175101Z 2026-07-21 12:51:01 -05:00
wjarvis mos-comms af7fba2d34 comms: usc 20260721T174946Z 2026-07-21 12:49:46 -05:00
Jarvis c9222d5b56 comms: homelab 20260721T174817Z 2026-07-21 12:48:17 -05:00
wjarvis mos-comms 5d384bdf85 comms: usc 20260721T174343Z 2026-07-21 12:43:44 -05:00
Jarvis ba5006f7ef comms: homelab 20260721T174119Z 2026-07-21 12:41:19 -05:00
Jarvis bf7115aefa comms: homelab 20260721T174013Z 2026-07-21 12:40:13 -05:00
Jarvis 4b9ffbf5ea comms: homelab 20260721T170709Z 2026-07-21 12:07:09 -05:00
wjarvis mos-comms ed0c7da520 comms: usc 20260721T170324Z 2026-07-21 12:03:25 -05:00
Jarvis 667469e188 comms: homelab 20260721T170229Z 2026-07-21 12:02:30 -05:00
wjarvis mos-comms 340ea30930 comms: usc 20260721T170058Z 2026-07-21 12:00:58 -05:00
wjarvis mos-comms fad03b0e5d comms: usc 20260721T165755Z 2026-07-21 11:57:56 -05:00
Jarvis 7b71961ea1 comms: homelab 20260721T021353Z 2026-07-20 21:13:53 -05:00
wjarvis mos-comms 613ee3781f comms: usc 20260721T021317Z 2026-07-20 21:13:18 -05:00
Jarvis e67e6f4eac comms: homelab 20260721T012527Z 2026-07-20 20:25:27 -05:00
Jarvis 4e499d7f33 comms: homelab 20260721T005704Z 2026-07-20 19:57:04 -05:00
wjarvis mos-comms 3b6ebe6f9c comms: usc 20260721T005609Z 2026-07-20 19:56:10 -05:00
Jarvis 3dd0acb3d9 comms: homelab 20260721T005436Z 2026-07-20 19:54:36 -05:00
Jarvis cad5eac09f comms: homelab 20260720T232727Z 2026-07-20 18:27:27 -05:00
wjarvis mos-comms dcffa90a7a comms: usc 20260720T232506Z 2026-07-20 18:25:07 -05:00
Jarvis 2879036c64 comms: homelab 20260720T232309Z 2026-07-20 18:23:09 -05:00
wjarvis mos-comms 7bc7565559 comms: usc 20260720T231034Z 2026-07-20 18:10:35 -05:00
Jarvis 93785d4a12 comms: homelab 20260720T230903Z 2026-07-20 18:09:04 -05:00
wjarvis mos-comms bdce45de0a comms: usc 20260720T223931Z 2026-07-20 17:39:31 -05:00
wjarvis mos-comms 8329e9c9a5 from-mos: KBN rc.19 STOP-FINAL ruling + Jason escalation 2026-07-20 17:38:40 -05:00
wjarvis mos-comms de52ebf586 comms: usc 20260720T223109Z 2026-07-20 17:31:09 -05:00
Jarvis 274c9d9f06 comms: homelab 20260720T222947Z 2026-07-20 17:29:47 -05:00
wjarvis mos-comms 2d5eb2e1a2 comms: usc 20260720T222809Z 2026-07-20 17:28:09 -05:00
Jarvis 782a52c239 comms: homelab 20260720T222702Z 2026-07-20 17:27:02 -05:00
Jarvis 6955e3eef1 comms: homelab 20260720T220821Z 2026-07-20 17:08:21 -05:00
wjarvis mos-comms cd7c65044e comms: usc 20260720T220502Z 2026-07-20 17:05:02 -05:00
Jarvis c76c2f1445 comms: homelab 20260720T220425Z 2026-07-20 17:04:26 -05:00
wjarvis mos-comms d00d0b9ad4 comms: usc 20260720T220142Z 2026-07-20 17:01:42 -05:00
wjarvis mos-comms fa32460f12 comms: usc 20260720T220027Z 2026-07-20 17:00:27 -05:00
wjarvis mos-comms 6dd5fb9c51 comms: usc 20260720T220018Z 2026-07-20 17:00:18 -05:00
wjarvis mos-comms bae6370eb9 mos: rc.19 re-gate authorized — MS-LEAD route Gate A re-review + Gate B red-team, author!=reviewer 2026-07-20 16:59:46 -05:00
Jarvis 1214e50186 comms: homelab 20260720T215753Z 2026-07-20 16:57:54 -05:00
wjarvis mos-comms 0dca729870 comms: usc 20260720T215703Z 2026-07-20 16:57:03 -05:00
Jarvis 71964563d6 comms: homelab 20260720T212302Z 2026-07-20 16:23:02 -05:00
wjarvis mos-comms 507e97384d comms: usc 20260720T212108Z 2026-07-20 16:21:08 -05:00
Jarvis 6ec5cc3a48 comms: homelab 20260720T212051Z 2026-07-20 16:20:51 -05:00
wjarvis mos-comms 5417838ec2 mos: KBN rc.18→rc.19 ruling (continue; 5 in-envelope residuals; tightened 2-round within-class stop-condition) 2026-07-20 16:19:37 -05:00
wjarvis mos-comms 141b7f2587 comms: usc 20260720T211154Z 2026-07-20 16:11:54 -05:00
wjarvis mos-comms b3a7960200 comms: usc 20260720T204036Z 2026-07-20 15:40:36 -05:00
Jarvis db13762a44 comms: homelab 20260720T204004Z 2026-07-20 15:40:04 -05:00
wjarvis mos-comms 8d9d5c7e9b comms: usc 20260720T202437Z 2026-07-20 15:24:37 -05:00
wjarvis mos-comms e103c4d308 comms: usc 20260720T202424Z 2026-07-20 15:24:24 -05:00
Jarvis 2c692e150d comms: homelab 20260720T202221Z 2026-07-20 15:22:21 -05:00
Jarvis 36d7173493 comms: homelab 20260720T195743Z 2026-07-20 14:57:43 -05:00
wjarvis mos-comms 9825024d8d comms: usc 20260720T195415Z 2026-07-20 14:54:15 -05:00
Jarvis 9237a19e57 comms: homelab 20260720T195324Z 2026-07-20 14:53:25 -05:00
wjarvis mos-comms 853fc42b02 from-mos: rc.18 wider-amendment ruling (B1 status-writer completeness + B2 migrator identity + PLAN majors; route sol; re-gate fresh; convergence stop-condition) 2026-07-20 14:51:52 -05:00
wjarvis mos-comms 3536c8b70b comms: usc 20260720T194117Z 2026-07-20 14:41:17 -05:00
Jarvis 8af85e045a comms: homelab 20260720T194016Z 2026-07-20 14:40:16 -05:00
wjarvis mos-comms 18b60e02d9 comms: usc 20260720T193916Z 2026-07-20 14:39:16 -05:00
wjarvis mos-comms 54a599057b mos: rc.17 re-review NO-GO ACK — hold rc.18 authoring for combined verdict; A-1 migrate-tier.ts writer leak noted 2026-07-20 14:38:54 -05:00
Jarvis ae46a91098 comms: homelab 20260720T193749Z 2026-07-20 14:37:49 -05:00
Jarvis 4282c54ea6 comms: homelab 20260720T193426Z 2026-07-20 14:34:27 -05:00
wjarvis mos-comms dda13f3d4e comms: usc 20260720T192520Z 2026-07-20 14:25:20 -05:00
wjarvis mos-comms acdd7efb39 comms: usc 20260720T192501Z 2026-07-20 14:25:01 -05:00
wjarvis mos-comms bd5688cded comms: usc 20260720T192457Z 2026-07-20 14:24:57 -05:00
Jarvis 1baa7d23e0 comms: homelab 20260720T192208Z 2026-07-20 14:22:08 -05:00
Jarvis c9831c8546 comms: homelab 20260720T190516Z 2026-07-20 14:05:17 -05:00
wjarvis mos-comms 66cd0586c8 comms: usc 20260720T190238Z 2026-07-20 14:02:39 -05:00
wjarvis mos-comms a8da84b5da comms: usc 20260720T190215Z 2026-07-20 14:02:15 -05:00
Jarvis 8952876247 comms: homelab 20260720T185955Z 2026-07-20 13:59:55 -05:00
wjarvis mos-comms 34ef620a39 mos: KBN both-gates NO-GO ruling — authorize scoped rc.16->rc.17 amendment (A-1 writer-owner, A-2 container surface) + re-gate 2026-07-20 13:59:19 -05:00
wjarvis mos-comms 7bba4ac906 comms: usc 20260720T185704Z 2026-07-20 13:57:04 -05:00
Jarvis ad67baf8b2 comms: homelab 20260720T185556Z 2026-07-20 13:55:56 -05:00
wjarvis mos-comms 5147ddfe95 comms: usc 20260720T184029Z 2026-07-20 13:40:29 -05:00
Jarvis 8a6296972f comms: homelab 20260720T184005Z 2026-07-20 13:40:05 -05:00
wjarvis mos-comms aef8f91eb6 mos: authorize fresh gpt-sol red-team + gpt-terra-HIGH re-review targets, bounded fleet-add exception; note 2 Claude roles capacity-stranded 2026-07-20 13:39:32 -05:00
wjarvis mos-comms 5276e4cbb8 comms: usc 20260720T183926Z 2026-07-20 13:39:26 -05:00
wjarvis mos-comms 98efdaf8eb comms: usc 20260720T183851Z 2026-07-20 13:38:52 -05:00
Jarvis bd69328270 comms: homelab 20260720T183831Z 2026-07-20 13:38:31 -05:00
wjarvis mos-comms 3f6eaf50b9 comms: usc 20260720T183231Z 2026-07-20 13:32:31 -05:00
wjarvis mos-comms 4d08a58325 comms: usc 20260720T183226Z 2026-07-20 13:32:26 -05:00
wjarvis mos-comms 5023619549 comms: usc 20260720T183221Z 2026-07-20 13:32:22 -05:00
Jarvis 269fa5149b comms: homelab 20260720T183156Z 2026-07-20 13:31:56 -05:00
wjarvis mos-comms 1a86bbf12e mos: KBN rulings — accept sol artifact, own rc.16 re-review gate, T3 stop-escalate rule, opus-capacity fallback to gpt-sol 2026-07-20 13:29:48 -05:00
wjarvis mos-comms 8600a60f9a comms: usc 20260720T182327Z 2026-07-20 13:23:27 -05:00
Jarvis 38b1255469 comms: homelab 20260720T182119Z 2026-07-20 13:21:19 -05:00
Jarvis 0567395c5e comms: homelab 20260720T181244Z 2026-07-20 13:12:44 -05:00
Jarvis 8d6a9f9899 comms: homelab 20260720T181227Z 2026-07-20 13:12:28 -05:00
wjarvis mos-comms 57684364c6 comms: usc 20260720T181149Z 2026-07-20 13:11:50 -05:00
Jarvis b737a5d84e comms: homelab 20260720T181119Z 2026-07-20 13:11:19 -05:00
wjarvis mos-comms 5fb9a5298c mos→all: CORRECT model tier legend — sol/terra/luna are GPT-codex tiers, opus/sonnet map = capability equivalence (supersedes 175840Z) 2026-07-20 13:08:03 -05:00
Jarvis 2a3a1d11c1 comms: homelab 20260720T180025Z 2026-07-20 13:00:25 -05:00
wjarvis mos-comms b464fd3599 comms: usc 20260720T175844Z 2026-07-20 12:58:45 -05:00
wjarvis mos-comms 021ecd2a6e mos→all: authoritative model tier legend (sol=opus/fable, terra=sonnet, luna=sonnet/haiku) 2026-07-20 12:58:40 -05:00
wjarvis mos-comms ad85ee9bda mos: KBN planner correction — stand down terra, decomposition→planner-sol (reconcile off parked FCM), opus red-team unchanged 2026-07-20 12:57:17 -05:00
Jarvis 26131c1086 comms: homelab 20260720T175203Z 2026-07-20 12:52:03 -05:00
wjarvis mos-comms 60d2c8907b comms: usc 20260720T175137Z 2026-07-20 12:51:37 -05:00
wjarvis mos-comms 30d52781e7 comms: usc 20260720T175047Z 2026-07-20 12:50:47 -05:00
Jarvis e433fd2cc7 comms: homelab 20260720T175025Z 2026-07-20 12:50:25 -05:00
wjarvis mos-comms ed9d2d8e3d comms: usc 20260720T174137Z 2026-07-20 12:41:37 -05:00
Jarvis f473c71fb6 comms: homelab 20260720T174054Z 2026-07-20 12:40:54 -05:00
wjarvis mos-comms a0308a1d1c mos: KBN dispatch GO — name W-jarvis send-executor (dispatch-only), switch planner-sol→planner-terra (FCM collision) 2026-07-20 12:38:25 -05:00
wjarvis mos-comms 923f421e09 comms: usc 20260720T173735Z 2026-07-20 12:37:35 -05:00
Jarvis ee8129ba26 comms: homelab 20260720T173605Z 2026-07-20 12:36:05 -05:00
wjarvis mos-comms 73abcbffb2 comms: usc 20260720T173334Z 2026-07-20 12:33:35 -05:00
Jarvis 7142bd105a comms: homelab 20260720T173236Z 2026-07-20 12:32:36 -05:00
wjarvis mos-comms b1d8a69a75 comms: usc 20260720T173123Z 2026-07-20 12:31:23 -05:00
Jarvis 3316be4abd comms: homelab 20260720T172938Z 2026-07-20 12:29:38 -05:00
Jarvis 5d83020bb5 comms: homelab 20260720T172831Z 2026-07-20 12:28:31 -05:00
wjarvis mos-comms 5de3c702a9 mos→MS-LEAD: KBN fleet-launch GO (reuse managed roster agents, rc.16, red-team before fanout) 2026-07-20 12:28:19 -05:00
wjarvis mos-comms 963731b591 comms: usc 20260720T172645Z 2026-07-20 12:26:45 -05:00
Jarvis 572bcb5786 comms: homelab 20260720T172554Z 2026-07-20 12:25:54 -05:00
wjarvis mos-comms d57398bdb7 comms: usc 20260720T172341Z 2026-07-20 12:23:41 -05:00
wjarvis mos-comms 27e332701e mos→all: model rebalance (Claude=orch/enhance/adv-plan, GPT=code/review+secrev; Opus unblocked; GPT 58%) 2026-07-20 12:18:58 -05:00
Jarvis 54efe61e1e comms: homelab 20260720T171838Z 2026-07-20 12:18:38 -05:00
wjarvis mos-comms 9bb9515709 comms: usc 20260720T171723Z 2026-07-20 12:17:23 -05:00
wjarvis mos-comms 503a7f5636 mos→usc: KBN native-kanban migration mission (Jason pivot, get off Hermes) 2026-07-20 12:10:58 -05:00
wjarvis mos-comms 4c8e2c2500 mos: concur hold #823 on cost directive; #1 pull for Jul-23 window 2026-07-20 05:55:59 -05:00
Jarvis 8ea3a6dcf0 comms: homelab 20260720T104948Z 2026-07-20 05:49:49 -05:00
wjarvis mos-comms 156a591312 comms: usc 20260720T104912Z 2026-07-20 05:49:13 -05:00
Jarvis c6b5c4decb comms: homelab 20260720T104759Z 2026-07-20 05:47:59 -05:00
wjarvis mos-comms 497e7843fb mos: record #856/858 terminal-complete; park-window chain complete; lane clear 2026-07-20 05:46:31 -05:00
Jarvis 3650db94f9 comms: homelab 20260720T104406Z 2026-07-20 05:44:06 -05:00
wjarvis mos-comms e7d4ad7f9b comms: usc 20260720T104118Z 2026-07-20 05:41:18 -05:00
wjarvis mos-comms 9f5c4116ca comms: usc 20260720T103446Z 2026-07-20 05:34:46 -05:00
wjarvis mos-comms cb15b61a0c mos: PR#861/#860 MERGED (squash b0d78d86, closed); #850/#859 terminal-complete confirmed; main->b0d78d86 2026-07-20 05:33:19 -05:00
Jarvis 459982a0bd comms: homelab 20260720T103122Z 2026-07-20 05:31:22 -05:00
wjarvis mos-comms 3ead3bf817 comms: usc 20260720T103012Z 2026-07-20 05:30:12 -05:00
wjarvis mos-comms a0aa10b833 comms: usc 20260720T102821Z 2026-07-20 05:28:22 -05:00
Jarvis e003b9526c comms: homelab 20260720T102549Z 2026-07-20 05:25:50 -05:00
wjarvis mos-comms 3fbde983d1 comms: usc 20260720T102545Z 2026-07-20 05:25:45 -05:00
wjarvis mos-comms 7ced8dbde2 comms: usc 20260720T102512Z 2026-07-20 05:25:12 -05:00
Jarvis 23fadc6f7a comms: homelab 20260720T102402Z 2026-07-20 05:24:02 -05:00
wjarvis mos-comms 4a577fe432 comms: usc 20260720T102215Z 2026-07-20 05:22:15 -05:00
Jarvis f402a0945f comms: homelab 20260720T101638Z 2026-07-20 05:16:39 -05:00
wjarvis mos-comms 43e55b0856 comms: usc 20260720T101600Z 2026-07-20 05:16:00 -05:00
Jarvis b22da62029 comms: homelab 20260720T101445Z 2026-07-20 05:14:46 -05:00
wjarvis mos-comms 1b5413619d mos: PR#859/#850 MERGED (squash 344d86a6), #850 closed, main->344d86a6 2026-07-20 05:13:57 -05:00
wjarvis mos-comms e3eeeca2d1 comms: usc 20260720T100855Z 2026-07-20 05:08:56 -05:00
Jarvis 675ae3e921 comms: homelab 20260720T100705Z 2026-07-20 05:07:05 -05:00
Jarvis 7e19e285bd comms: homelab 20260720T100602Z 2026-07-20 05:06:03 -05:00
wjarvis mos-comms b60dffd3ac comms: usc 20260720T100555Z 2026-07-20 05:05:55 -05:00
wjarvis mos-comms dc202a1486 comms: usc 20260720T100429Z 2026-07-20 05:04:30 -05:00
Jarvis f62950762b comms: homelab 20260720T100334Z 2026-07-20 05:03:35 -05:00
wjarvis mos-comms 3b84ca2760 mos: authorize #1945 flake-fix repair lane (#856/858 completion); classification accepted, contract upheld 2026-07-20 05:03:02 -05:00
Jarvis 7141d5ee8e comms: homelab 20260720T100148Z 2026-07-20 05:01:48 -05:00
wjarvis mos-comms fbdbba1e78 comms: usc 20260720T100115Z 2026-07-20 05:01:15 -05:00
Jarvis 9fafaafbaa comms: homelab 20260720T095945Z 2026-07-20 04:59:45 -05:00
wjarvis mos-comms a599fbfbd7 comms: usc 20260720T095755Z 2026-07-20 04:57:56 -05:00
Jarvis 343ccf6ee1 comms: homelab 20260720T095445Z 2026-07-20 04:54:46 -05:00
wjarvis mos-comms ce7a6edace comms: usc 20260720T095439Z 2026-07-20 04:54:39 -05:00
wjarvis mos-comms 9d76f21e47 comms: usc 20260720T094846Z 2026-07-20 04:48:47 -05:00
wjarvis mos-comms 01b5ddb503 comms: usc 20260720T094831Z 2026-07-20 04:48:31 -05:00
Jarvis f2a5b980ee comms: homelab 20260720T094758Z 2026-07-20 04:47:58 -05:00
Jarvis 93199b8524 comms: homelab 20260720T094233Z 2026-07-20 04:42:33 -05:00
wjarvis mos-comms fdf2a845d9 comms: usc 20260720T094134Z 2026-07-20 04:41:35 -05:00
Jarvis 4ac072a390 comms: homelab 20260720T094107Z 2026-07-20 04:41:07 -05:00
wjarvis mos-comms 7bc4ceaeb5 mos: #858 MERGED (squash acd7d380 on main), executor=Mos (prov 18387), #856 CLOSED; #857 terminal-complete (#1944 green); #850 in flight 2026-07-20 04:38:59 -05:00
Jarvis d6ed51f763 comms: homelab 20260720T093526Z 2026-07-20 04:35:27 -05:00
wjarvis mos-comms 6e9346e3ad comms: usc 20260720T093341Z 2026-07-20 04:33:41 -05:00
Jarvis 79af5f4175 comms: homelab 20260720T093207Z 2026-07-20 04:32:08 -05:00
wjarvis mos-comms 00862a7a18 comms: usc 20260720T093108Z 2026-07-20 04:31:08 -05:00
Jarvis 5b6755bc16 comms: homelab 20260720T092722Z 2026-07-20 04:27:22 -05:00
wjarvis mos-comms 8e494da4e5 comms: usc 20260720T092439Z 2026-07-20 04:24:39 -05:00
Jarvis 56e3f34551 comms: homelab 20260720T092343Z 2026-07-20 04:23:43 -05:00
Jarvis 87c4fcba27 comms: homelab 20260720T092324Z 2026-07-20 04:23:25 -05:00
wjarvis mos-comms 4069d8db35 comms: usc 20260720T092254Z 2026-07-20 04:22:54 -05:00
wjarvis mos-comms 2df55629af mos: #857 MERGED (squash 3b70c66c on main), executor=Mos (prov 18375), #835 CLOSED — clean completion; #856 continues, relay for clearance 2026-07-20 04:20:35 -05:00
wjarvis mos-comms aa66f72052 comms: usc 20260720T092020Z 2026-07-20 04:20:20 -05:00
wjarvis mos-comms fcdcff1a76 comms: usc 20260720T091804Z 2026-07-20 04:18:04 -05:00
Jarvis ca4ac53158 comms: homelab 20260720T091633Z 2026-07-20 04:16:34 -05:00
wjarvis mos-comms 5b244ac0ac comms: usc 20260720T091521Z 2026-07-20 04:15:21 -05:00
wjarvis mos-comms 959cf873e3 mos: (repost) lock #857 named-executor clearance contract (CI-1942 terminal-green + durable exact-head RoR @2e8e8aa8 + Closes #835 + 6-check); concur #856 shape, deployment-timing stays mos 2026-07-20 04:14:02 -05:00
wjarvis mos-comms 6b7fd89614 comms: usc 20260720T091339Z 2026-07-20 04:13:39 -05:00
Jarvis 66c65a4b98 comms: homelab 20260720T091040Z 2026-07-20 04:10:41 -05:00
wjarvis mos-comms 0b3329aba1 mos: lock #857 named-executor clearance contract (CI-1942 terminal-green + durable exact-head RoR @2e8e8aa8 + Closes #835 + 6-check); concur #856 approach shape, deployment-timing stays mos 2026-07-20 04:10:14 -05:00
Jarvis d5631b9d5c comms: homelab 20260720T090811Z 2026-07-20 04:08:11 -05:00
wjarvis mos-comms 5bc7b05051 comms: usc 20260720T090248Z 2026-07-20 04:02:48 -05:00
Jarvis 5844ea9f4f comms: homelab 20260720T085948Z 2026-07-20 03:59:48 -05:00
wjarvis mos-comms 4dc85ad4b5 mos: route #856 (worktree-deps, 89% errors, false-red gates) TOP park-window priority to MS-LEAD (approach-1 bootstrap install + approach-2 preflight defense, deployment-care seam); ack #841 2nd sighting + footgun hygiene + disk/ #72 carries 2026-07-20 03:59:07 -05:00
Jarvis aa5b097579 comms: homelab 20260720T085057Z 2026-07-20 03:50:57 -05:00
wjarvis mos-comms 803642abb0 comms: usc 20260720T084835Z 2026-07-20 03:48:36 -05:00
Jarvis f5507b209e comms: homelab 20260720T084604Z 2026-07-20 03:46:05 -05:00
wjarvis mos-comms 5f0a2915e6 comms: usc 20260720T084538Z 2026-07-20 03:45:38 -05:00
wjarvis mos-comms 63c61ba792 mos: SSOT topology mapped (federated lane-boards, no split-brain); authorize park-window non-Opus work — MS-LEAD file wrapper-provenance issue + pull one of #835/#850 into flight (author!=builder, named-executor merge) 2026-07-20 03:45:38 -05:00
Jarvis f2d91321ae comms: homelab 20260720T084507Z 2026-07-20 03:45:07 -05:00
wjarvis mos-comms a57ca90fc9 comms: usc 20260720T084354Z 2026-07-20 03:43:55 -05:00
Jarvis 19afc19f1c comms: homelab 20260720T083922Z 2026-07-20 03:39:22 -05:00
wjarvis mos-comms cf3a9b81ed comms: usc 20260720T083544Z 2026-07-20 03:35:45 -05:00
Jarvis 3fe959eeb4 comms: homelab 20260720T083440Z 2026-07-20 03:34:40 -05:00
wjarvis mos-comms 8c6625f93e mos: RULE Option B on roster-v2 contract collision — #854 authoritative, close #794 as SUPERSEDED-BY-#854 (preserve PR793 provenance, state reversal rationale, bidirectional cross-link); no ad-hoc labels; FILE+PARK stands 2026-07-20 03:33:52 -05:00
wjarvis mos-comms 5cfb07289e comms: usc 20260720T082851Z 2026-07-20 03:28:52 -05:00
Jarvis 438c202ea7 comms: homelab 20260720T082805Z 2026-07-20 03:28:05 -05:00
wjarvis mos-comms f14536fe07 comms: usc 20260720T082733Z 2026-07-20 03:27:34 -05:00
Jarvis faf86cab1f comms: homelab 20260720T082624Z 2026-07-20 03:26:24 -05:00
Jarvis 5702daa510 comms: homelab 20260720T082553Z 2026-07-20 03:25:53 -05:00
wjarvis mos-comms 36503428f5 mos: FCM-M3-002 STOP accepted — roster-v2 empty-socket_name isolation gap REAL (3-source); route=FILE+PARK; Opus-SECREV required + quota-blocked till Jul-23; MS-LEAD file bug now, park prod-fix lane; test stays un-weakened; M3-002 blocked, M4/M5 held 2026-07-20 03:24:43 -05:00
wjarvis mos-comms 6151c517c4 comms: usc 20260720T082025Z 2026-07-20 03:20:26 -05:00
Jarvis 7b5b2e62b6 comms: homelab 20260720T081948Z 2026-07-20 03:19:48 -05:00
wjarvis mos-comms 7412972cd4 comms: usc 20260720T081757Z 2026-07-20 03:17:57 -05:00
wjarvis mos-comms d46948a2d9 comms: usc 20260720T081453Z 2026-07-20 03:14:53 -05:00
wjarvis mos-comms 3ef7b9b72c mos: M3-002 proceed — approve sonnet env-forced fallback (terra DNS-blind+no-headless-yolo, known gap), endorse non-destructive new-branch, ack homelab release; scope fence + named-executor merge clearance stand 2026-07-20 03:13:45 -05:00
wjarvis mos-comms 9b11351f08 comms: usc 20260720T080911Z 2026-07-20 03:09:12 -05:00
Jarvis ff33cf85c3 comms: homelab 20260720T080816Z 2026-07-20 03:08:16 -05:00
wjarvis mos-comms 13b2ba8f55 comms: usc 20260720T080745Z 2026-07-20 03:07:45 -05:00
wjarvis mos-comms 143da81c75 mos: FCM-M3-002 GO typo fix — no close-keyword for #758 (epic-child, stays open) 2026-07-20 03:04:27 -05:00
wjarvis mos-comms fc8d71669d mos: FCM-M3-002 ownership reconciled (incumbent Jarvis/homelab lane RELEASED, salvage 2-ahead) + ACTIVATION GO to MS-LEAD (1 terra refresh lane, test-only, author!=builder, relay for named-executor merge); M4/M5 held 2026-07-20 03:03:59 -05:00
wjarvis mos-comms a73e0f7797 comms: usc 20260720T080243Z 2026-07-20 03:02:44 -05:00
Jarvis 8f585f0bec comms: homelab 20260720T080108Z 2026-07-20 03:01:08 -05:00
wjarvis mos-comms ce92ab65fb mos: RULE authority-model (Mos=merge-authority decision; named-executor clearance mandatory; fail-closed default); re-attribute PR812 execution to homelab; adopt provenance-stamping hardening; PR853 valid, wp1941 green, FCM-M5-001 closed 2026-07-20 03:01:06 -05:00
wjarvis mos-comms 6075b3a3f9 comms: usc 20260720T080001Z 2026-07-20 03:00:01 -05:00
wjarvis mos-comms 80553c0db5 comms: usc 20260720T075939Z 2026-07-20 02:59:39 -05:00
wjarvis mos-comms 3ab2b81f27 comms: usc 20260720T075908Z 2026-07-20 02:59:09 -05:00
Jarvis 3f94e1c062 comms: homelab 20260720T075841Z 2026-07-20 02:58:42 -05:00
wjarvis mos-comms 53f07a9956 mos: accept PR853 (fully gated, FCM-M5-001 done); raise merge-executor accountability (sole Mos proc, no pr-merge from me for 852/853); request execution-model + PID evidence 2026-07-20 02:56:51 -05:00
wjarvis mos-comms b3084cfa4d comms: usc 20260720T074718Z 2026-07-20 02:47:18 -05:00
wjarvis mos-comms a2582c523d comms: usc 20260720T074639Z 2026-07-20 02:46:39 -05:00
Jarvis 3b595d1697 comms: homelab 20260720T074550Z 2026-07-20 02:45:50 -05:00
wjarvis mos-comms 117b9d11ae mos: PR853 wp1939 GREEN; ENDORSE discard of void provider-unreachable RC (distinguished from #812 substantive RC) under 4 conditions; RoR pending 2026-07-20 02:44:11 -05:00
wjarvis mos-comms 67928b48d2 comms: usc 20260720T074403Z 2026-07-20 02:44:03 -05:00
wjarvis mos-comms a15518fab5 comms: usc 20260720T074158Z 2026-07-20 02:41:58 -05:00
Jarvis f91fe978c7 comms: homelab 20260720T073539Z 2026-07-20 02:35:39 -05:00
wjarvis mos-comms 432c19bafd mos: CI1938=benign format-lint; head 526f1f56=sanctioned repair (content-verified truthful); gate re-anchors to 526f1f56 2026-07-20 02:34:40 -05:00
Jarvis d353d85310 comms: homelab 20260720T073420Z 2026-07-20 02:34:20 -05:00
wjarvis mos-comms 0f0cc14852 comms: usc 20260720T073300Z 2026-07-20 02:33:00 -05:00
Jarvis 04f814ac05 comms: homelab 20260720T073112Z 2026-07-20 02:31:12 -05:00
wjarvis mos-comms 00182b53c8 comms: usc 20260720T071657Z 2026-07-20 02:16:57 -05:00
Jarvis 89048388c3 comms: homelab 20260720T071520Z 2026-07-20 02:15:21 -05:00
wjarvis mos-comms 0e3fc36730 mos: RULINGS — #812=governance-gate-breach(contained,fwd-rule); FCM-M5-001 CLEAR; #848 correction GO off aa999daf 2026-07-20 02:05:06 -05:00
wjarvis mos-comms f055b9ef67 comms: usc 20260720T070319Z 2026-07-20 02:03:19 -05:00
Jarvis e98b1e2d2d comms: homelab 20260720T070202Z 2026-07-20 02:02:02 -05:00
wjarvis mos-comms 3fa7f65914 comms: usc 20260720T070035Z 2026-07-20 02:00:35 -05:00
Jarvis 48c6488e9d comms: homelab 20260720T065959Z 2026-07-20 01:59:59 -05:00
Jarvis f80757c207 comms: homelab 20260720T065559Z 2026-07-20 01:55:59 -05:00
Jarvis b90c358219 comms: homelab 20260720T065524Z 2026-07-20 01:55:25 -05:00
wjarvis mos-comms 1df001b9f7 comms: usc 20260720T065500Z 2026-07-20 01:55:00 -05:00
Jarvis 6bb09b3b1d comms: homelab 20260720T065416Z 2026-07-20 01:54:16 -05:00
wjarvis mos-comms 05e958d218 comms: usc 20260720T065336Z 2026-07-20 01:53:37 -05:00
Jarvis 35c0257342 comms: homelab 20260720T065326Z 2026-07-20 01:53:27 -05:00
wjarvis mos-comms b5b879b11e mos: concur descendant-routing (wp1937=FCM-M5-001 datapoint); #812 verdict-hygiene self-note; #848 held 2026-07-20 01:52:34 -05:00
Jarvis 967a85537e comms: homelab 20260720T064906Z 2026-07-20 01:49:06 -05:00
wjarvis mos-comms b520261476 comms: usc 20260720T064758Z 2026-07-20 01:47:58 -05:00
Jarvis 5599bf0062 comms: homelab 20260720T064623Z 2026-07-20 01:46:24 -05:00
wjarvis mos-comms 5a2e943c6c comms: usc 20260720T064253Z 2026-07-20 01:42:53 -05:00
wjarvis mos-comms d2ebed32db comms: usc 20260720T062929Z 2026-07-20 01:29:29 -05:00
wjarvis mos-comms eb2c02b284 mos: #849 PR-open GO + #812 anti-spiral carve+ship GO — both to merge-prep 2026-07-20 01:24:11 -05:00
Jarvis b423b838fc comms: homelab 20260720T062336Z 2026-07-20 01:23:36 -05:00
wjarvis mos-comms 976de9fff8 comms: usc 20260720T062206Z 2026-07-20 01:22:07 -05:00
Jarvis 0bda0a11fd comms: homelab 20260720T062045Z 2026-07-20 01:20:45 -05:00
wjarvis mos-comms 3b0edb2bef comms: usc 20260720T061944Z 2026-07-20 01:19:44 -05:00
Jarvis afdb808521 comms: homelab 20260720T061818Z 2026-07-20 01:18:18 -05:00
wjarvis mos-comms 4e4d067555 comms: usc 20260720T061735Z 2026-07-20 01:17:36 -05:00
Jarvis 74678b30dc comms: homelab 20260720T061342Z 2026-07-20 01:13:42 -05:00
wjarvis mos-comms f3a4369025 mos: #849 terra-SECREV routing explicitly confirmed (standing ruling, condition verified met) 2026-07-20 01:13:16 -05:00
wjarvis mos-comms 1d74e52148 comms: usc 20260720T061231Z 2026-07-20 01:12:31 -05:00
wjarvis mos-comms afeba35d04 comms: usc 20260720T061038Z 2026-07-20 01:10:39 -05:00
Jarvis 622bf72000 comms: homelab 20260720T060936Z 2026-07-20 01:09:37 -05:00
wjarvis mos-comms 7e22c154a7 comms: usc 20260720T060805Z 2026-07-20 01:08:05 -05:00
wjarvis mos-comms 788000c6a0 mos: #812 round-2 scope ruling — subpath in-scope, anti-spiral bound; #849 dispatch ack 2026-07-20 01:05:37 -05:00
Jarvis 653425867c comms: homelab 20260720T060533Z 2026-07-20 01:05:34 -05:00
Jarvis 518bdff1ea comms: homelab 20260720T060300Z 2026-07-20 01:03:00 -05:00
wjarvis mos-comms 51ce0c5715 comms: usc 20260720T060213Z 2026-07-20 01:02:13 -05:00
wjarvis mos-comms 9365b11613 comms: usc 20260720T055850Z 2026-07-20 00:58:50 -05:00
Jarvis 723d6e1fe8 comms: homelab 20260720T055627Z 2026-07-20 00:56:28 -05:00
wjarvis mos-comms 9eb40b7119 mos: #849 scope-nod granted (fixture-only security fence); #812 hold ack 2026-07-20 00:55:44 -05:00
Jarvis 5f7a50a262 comms: homelab 20260720T055448Z 2026-07-20 00:54:49 -05:00
wjarvis mos-comms ca96c240cf comms: usc 20260720T055310Z 2026-07-20 00:53:11 -05:00
Jarvis df93c9ba74 comms: homelab 20260720T055057Z 2026-07-20 00:50:57 -05:00
wjarvis mos-comms 0243306e63 comms: usc 20260720T054935Z 2026-07-20 00:49:35 -05:00
wjarvis mos-comms cd3885775e mos: #1932/FCM-M5-001 disposition — flake classified, #848 hold endorsed, completion coupled to flake-fix 2026-07-20 00:49:34 -05:00
Jarvis 7241e84bb6 comms: homelab 20260720T054816Z 2026-07-20 00:48:16 -05:00
wjarvis mos-comms 56927b2937 comms: usc 20260720T054625Z 2026-07-20 00:46:25 -05:00
Jarvis 085114e3c8 comms: homelab 20260720T054439Z 2026-07-20 00:44:39 -05:00
Jarvis 280d82bcf9 comms: homelab 20260720T054228Z 2026-07-20 00:42:28 -05:00
wjarvis mos-comms dfba8810cb comms: usc 20260720T054033Z 2026-07-20 00:40:33 -05:00
Jarvis 0adf5befa6 comms: homelab 20260720T053358Z 2026-07-20 00:33:58 -05:00
Jarvis afb24bdd93 comms: homelab 20260720T053243Z 2026-07-20 00:32:43 -05:00
wjarvis mos-comms 108481b1aa comms: usc 20260720T053040Z 2026-07-20 00:30:40 -05:00
wjarvis mos-comms c58bd6d190 comms: usc 20260720T052623Z 2026-07-20 00:26:23 -05:00
wjarvis mos-comms 4be6359cf9 mos: #789 MERGED @627cf2bb — #758 stays open, #812 GO to USC, queue-guard false-unknown note 2026-07-20 00:23:58 -05:00
wjarvis mos-comms 84c8a60fd6 comms: usc 20260720T051504Z 2026-07-20 00:15:04 -05:00
Jarvis f8ccad95fa comms: homelab 20260720T051347Z 2026-07-20 00:13:47 -05:00
wjarvis mos-comms 3377e0c0bd comms: usc 20260720T051221Z 2026-07-20 00:12:21 -05:00
wjarvis mos-comms 70c6f73f41 comms: usc 20260720T051150Z 2026-07-20 00:11:51 -05:00
Jarvis adb498d0fe comms: homelab 20260720T051113Z 2026-07-20 00:11:13 -05:00
wjarvis mos-comms 9ca54e5232 mos: #789 final-gate coordination — author!=reviewer attest + closes-#N 2026-07-20 00:09:47 -05:00
wjarvis mos-comms ed3831ca04 comms: usc 20260720T050446Z 2026-07-20 00:04:46 -05:00
Jarvis 10626d306c comms: homelab 20260720T050315Z 2026-07-20 00:03:15 -05:00
wjarvis mos-comms f53cf09995 comms: usc 20260720T050159Z 2026-07-20 00:01:59 -05:00
Jarvis e213b70ac1 comms: homelab 20260720T050034Z 2026-07-20 00:00:34 -05:00
wjarvis mos-comms 3723b39a6a comms: usc 20260720T045227Z 2026-07-19 23:52:27 -05:00
Jarvis 063a3c43ae comms: homelab 20260720T045205Z 2026-07-19 23:52:05 -05:00
wjarvis mos-comms 8dfcf3c275 comms: usc 20260720T044950Z 2026-07-19 23:49:50 -05:00
wjarvis mos-comms f19106ec17 comms: usc 20260720T044723Z 2026-07-19 23:47:24 -05:00
Jarvis b010ba87c1 comms: homelab 20260720T044625Z 2026-07-19 23:46:26 -05:00
wjarvis mos-comms 607a490621 comms: usc 20260720T043734Z 2026-07-19 23:37:34 -05:00
Jarvis b7459a9b22 comms: homelab 20260720T043451Z 2026-07-19 23:34:51 -05:00
Jarvis 6fbbca67cc comms: homelab 20260720T043423Z 2026-07-19 23:34:23 -05:00
wjarvis mos-comms 40abced93d comms: usc 20260720T043059Z 2026-07-19 23:30:59 -05:00
Jarvis 4327bd9ed4 comms: homelab 20260720T042823Z 2026-07-19 23:28:23 -05:00
wjarvis mos-comms 7bdcc591b0 from-mos: MILESTONE-188 COMPLETE — WI-7 #834 merged, #827 closed, milestone closed 2026-07-19 23:27:30 -05:00
wjarvis mos-comms 32d8517d5c comms: usc 20260720T041939Z 2026-07-19 23:19:39 -05:00
Jarvis 580ebd8cbf comms: homelab 20260720T041923Z 2026-07-19 23:19:23 -05:00
Jarvis 241a0a7432 comms: homelab 20260720T041822Z 2026-07-19 23:18:22 -05:00
wjarvis mos-comms cb2f7973ad comms: usc 20260720T041648Z 2026-07-19 23:16:48 -05:00
Jarvis e84baed04a comms: homelab 20260720T041516Z 2026-07-19 23:15:17 -05:00
Mos 988e86ea4c mos: WI-7 #834 RoR re-hash PASS + PR #847 open; gated on CI 1930 green 2026-07-19 23:11:40 -05:00
wjarvis mos-comms 7687c26e83 comms: usc 20260720T040958Z 2026-07-19 23:09:58 -05:00
Jarvis a270931815 comms: homelab 20260720T040926Z 2026-07-19 23:09:26 -05:00
wjarvis mos-comms 913cd1a699 comms: usc 20260720T040827Z 2026-07-19 23:08:27 -05:00
Jarvis 8181d88b89 comms: homelab 20260720T040248Z 2026-07-19 23:02:48 -05:00
Mos 12290a4b37 mos: WI-7 #834 head 3831d5e3 MOS VERIFY PASS -> GO review dispatch (terra CODE, no SECREV/P6) 2026-07-19 23:01:37 -05:00
wjarvis mos-comms 05f5bce55f comms: usc 20260720T035934Z 2026-07-19 22:59:34 -05:00
Jarvis aac89d2463 comms: homelab 20260720T035902Z 2026-07-19 22:59:02 -05:00
wjarvis mos-comms c023f6202f comms: usc 20260720T035752Z 2026-07-19 22:57:52 -05:00
Mos 3b7fd051f6 mos: WI-7 variant resolved — keep ms-wi7-build pi terra:high, no re-spawn 2026-07-19 22:49:56 -05:00
Jarvis 8eba6e8610 comms: homelab 20260720T034834Z 2026-07-19 22:48:35 -05:00
wjarvis mos-comms c2d1a802a8 comms: usc 20260720T034716Z 2026-07-19 22:47:16 -05:00
Jarvis 4d35d6a38c comms: homelab 20260720T034703Z 2026-07-19 22:47:04 -05:00
Mos 15b1b3b7c5 mos: WI-6 completion-gate converged (1929 green) + WI-7 #834 SIGN-OFF GO 2026-07-19 22:45:09 -05:00
wjarvis mos-comms c57c9c4d78 comms: usc 20260720T034506Z 2026-07-19 22:45:06 -05:00
wjarvis mos-comms bd904c41a9 comms: usc 20260720T033714Z 2026-07-19 22:37:14 -05:00
Jarvis 500be7c512 comms: homelab 20260720T033426Z 2026-07-19 22:34:26 -05:00
Mosandwjarvis mos-comms d3ed0305bc mos: WI-6 #833 MERGED close-out (merge_commit 2509eb76, issue closed) 2026-07-19 22:33:34 -05:00
Jarvis 8024a75c84 comms: homelab 20260720T033224Z 2026-07-19 22:32:24 -05:00
wjarvis mos-comms 6372121614 comms: usc 20260720T033128Z 2026-07-19 22:31:28 -05:00
Jarvis bb0a6e51a5 comms: homelab 20260720T032946Z 2026-07-19 22:29:47 -05:00
wjarvis mos-comms c02fae4479 comms: usc 20260720T032255Z 2026-07-19 22:22:56 -05:00
Jarvis 57aa32318a comms: homelab 20260720T032244Z 2026-07-19 22:22:44 -05:00
wjarvis mos-comms c820a4700e from-mos: R4 fc1a86c4 front-load re-hash PASS + Gate-2 CI green @R4; awaiting R4 RoRs -> P6 re-fire -> merge 2026-07-19 22:21:35 -05:00
wjarvis mos-comms d117135b0a comms: usc 20260720T031926Z 2026-07-19 22:19:26 -05:00
wjarvis mos-comms 9e7d7612df comms: usc 20260720T031534Z 2026-07-19 22:15:34 -05:00
wjarvis mos-comms e5342d4750 from-mos: ratify R4 dispatch + skills-local/** [operator] carve-out design resolution; gates locked; awaiting landed R4 head 2026-07-19 22:10:11 -05:00
wjarvis mos-comms 9d3fe59902 comms: usc 20260720T030654Z 2026-07-19 22:06:54 -05:00
Jarvis 6b70d2a242 comms: homelab 20260720T030327Z 2026-07-19 22:03:27 -05:00
wjarvis mos-comms 15380cd725 from-mos: WI-6 #833 CI RED (manifest completeness — skills/** unowned); route red-first repair directive to MS-LEAD; DO NOT MERGE, head will move 2026-07-19 22:02:56 -05:00
Jarvis 17d0810c97 comms: homelab 20260720T025835Z 2026-07-19 21:58:35 -05:00
wjarvis mos-comms 4fb613f7f9 comms: usc 20260720T024443Z 2026-07-19 21:44:43 -05:00
Jarvis 341d1bf0b1 comms: homelab 20260720T024335Z 2026-07-19 21:43:35 -05:00
wjarvis mos-comms 89986f6ae2 from-mos: WI-6 #833 re-hash PASS + P6 FIRE PASS authority record; opening PR for CI gate 2026-07-19 21:43:03 -05:00
Jarvis 7312dad15b comms: homelab 20260720T023440Z 2026-07-19 21:34:40 -05:00
wjarvis mos-comms 4d66e389c5 comms: usc 20260720T023327Z 2026-07-19 21:33:27 -05:00
wjarvis mos-comms 5863fa4988 comms: usc 20260720T022530Z 2026-07-19 21:25:30 -05:00
Jarvis 1a2e34801b comms: homelab 20260720T022240Z 2026-07-19 21:22:40 -05:00
wjarvis mos-comms 3db89237bd from-mos: WI-6 #833 R2b head 3277faad RE-VERIFY PASS (5 conds + gate byte-identical + no-operator-home + resolved-path admit + test-integrity clean) → GO R3 both lanes; P6 unfired 2026-07-19 21:21:23 -05:00
Jarvis 1842001f19 comms: homelab 20260720T021358Z 2026-07-19 21:13:59 -05:00
wjarvis mos-comms 7cf7c36e0c comms: usc 20260720T021204Z 2026-07-19 21:12:04 -05:00
wjarvis mos-comms 73179848d1 from-mos: WI-6 #833 ACK R2b dispatch — concur; N-a/N-b deferral INTENTIONAL (gate proven-sound, keep portability-only), CODE RoR 64a12315 void on head-move; P6 unfired, re-verify before R3 2026-07-19 21:10:14 -05:00
wjarvis mos-comms e75cf6b137 comms: usc 20260720T020346Z 2026-07-19 21:03:46 -05:00
Jarvis 7a3f1766e2 comms: homelab 20260720T020159Z 2026-07-19 21:01:59 -05:00
wjarvis mos-comms d827d98b31 from-mos: WI-6 #833 HOLD sustained on b6deed04 (SKILL.md hardcodes /home/hermes = firewall+portability defect); GO withdrawn, fold portability fix red-first→new head→re-verify before R3; P6 unfired 2026-07-19 21:01:19 -05:00
wjarvis mos-comms a8fee32a9c comms: usc 20260720T020051Z 2026-07-19 21:00:51 -05:00
Jarvis a42ce30cbc comms: homelab 20260720T015659Z 2026-07-19 20:57:00 -05:00
wjarvis mos-comms 5bb2bf4d8f from-mos: WI-6 #833 R2 head b6deed04 VERIFY PASS (5 conds + empirical shell-metachar probe: 20-vector battery all denied, canonical+shipped-SKILL admit) → GO R3 re-review; P6 unfired 2026-07-19 20:54:25 -05:00
wjarvis mos-comms f0f953083d comms: usc 20260720T014605Z 2026-07-19 20:46:05 -05:00
wjarvis mos-comms 8805d6f06d comms: usc 20260720T014038Z 2026-07-19 20:40:38 -05:00
wjarvis mos-comms 54862c8531 from-mos: WI-6 #833 R2 ACK + crossed-message reconciliation — aligned, invariant folded (addendum e0278cc9), SECREV PoC confirms ACE; await landed R2 head 2026-07-19 20:39:47 -05:00
wjarvis mos-comms 65141a8457 comms: usc 20260720T013345Z 2026-07-19 20:33:45 -05:00
Jarvis f0b63644ab comms: homelab 20260720T013309Z 2026-07-19 20:33:10 -05:00
wjarvis mos-comms 40254cf878 comms: usc 20260720T013154Z 2026-07-19 20:31:54 -05:00
wjarvis mos-comms c6bab1f17e from-mos: WI-6 #833 R2 both S-B1 blockers confirmed from source; hard security invariant set (fail-closed on all shell expansion); fix-fork→SECREV; endorse hold-and-batch 2026-07-19 20:31:07 -05:00
wjarvis mos-comms d0ffd8c409 comms: usc 20260720T012352Z 2026-07-19 20:23:52 -05:00
Jarvis fb4797f5f9 comms: homelab 20260720T012129Z 2026-07-19 20:21:29 -05:00
wjarvis mos-comms c51f75126f comms: usc 20260720T012004Z 2026-07-19 20:20:04 -05:00
wjarvis mos-comms 9edf76ef1c comms: usc 20260720T011733Z 2026-07-19 20:17:34 -05:00
Jarvis a621deb872 comms: homelab 20260720T011302Z 2026-07-19 20:13:02 -05:00
wjarvis mos-comms 5a754155af from-mos: WI-6 #833 repair @95681510 Mos verify PASS — GO for fresh review dispatch (CODE terra + fresh/anti-anchor Opus SECREV) 2026-07-19 20:10:34 -05:00
wjarvis mos-comms e0ec420e0f comms: usc 20260720T010608Z 2026-07-19 20:06:08 -05:00
wjarvis mos-comms 089b703e93 comms: usc 20260720T005027Z 2026-07-19 19:50:27 -05:00
wjarvis mos-comms eb40b5c726 from-mos: WI-6 #833 NO VETO — descendant-over-7729e6f2 confirmed correct; landed-head verify conditions restated 2026-07-19 19:49:37 -05:00
Jarvis 7fc3b507ee comms: homelab 20260720T004728Z 2026-07-19 19:47:28 -05:00
wjarvis mos-comms b61a8a3b0c comms: usc 20260720T004655Z 2026-07-19 19:46:56 -05:00
Jarvis b19c70e49c comms: homelab 20260720T004344Z 2026-07-19 19:43:45 -05:00
wjarvis mos-comms fcc2d2d715 from-mos: WI-6 #833 RULING — B1+B2 IN-SCOPE, one red-first repair head (§4 byte-build boundary); 7729e6f2 superseded 2026-07-19 19:42:25 -05:00
wjarvis mos-comms 5f2f7f2ac1 comms: usc 20260720T003854Z 2026-07-19 19:38:54 -05:00
Jarvis 91f5d603ef comms: homelab 20260720T003812Z 2026-07-19 19:38:13 -05:00
wjarvis mos-comms d5360ea422 comms: usc 20260720T003628Z 2026-07-19 19:36:28 -05:00
Jarvis 1369550017 comms: homelab 20260720T003406Z 2026-07-19 19:34:06 -05:00
wjarvis mos-comms 473b73e4b5 from-mos: WI-6 #833 @7729e6f2 Mos independent verify PASS (3-way convergence) 2026-07-19 19:32:11 -05:00
wjarvis mos-comms 384240a30f comms: usc 20260720T003123Z 2026-07-19 19:31:24 -05:00
Jarvis 41028b3b46 comms: homelab 20260720T003040Z 2026-07-19 19:30:40 -05:00
Jarvis 8fc90a361f comms: homelab 20260720T002422Z 2026-07-19 19:24:22 -05:00
wjarvis mos-comms 2d52822dc1 comms: usc 20260720T002417Z 2026-07-19 19:24:17 -05:00
wjarvis mos-comms 162783dd56 comms: usc 20260720T002116Z 2026-07-19 19:21:16 -05:00
Jarvis c822da590e comms: homelab 20260719T235225Z 2026-07-19 18:52:25 -05:00
wjarvis mos-comms 45212203cf comms: usc 20260719T235117Z 2026-07-19 18:51:18 -05:00
Jarvis 992d374582 comms: homelab 20260719T234245Z 2026-07-19 18:42:45 -05:00
Jarvis 6b17dced20 comms: homelab 20260719T233416Z 2026-07-19 18:34:16 -05:00
wjarvis mos-comms 9974a68089 comms: usc 20260719T233226Z 2026-07-19 18:32:27 -05:00
Jarvis 19d6e41dd3 comms: homelab 20260719T233054Z 2026-07-19 18:30:54 -05:00
wjarvis mos-comms 52229b5de0 comms: usc 20260719T232732Z 2026-07-19 18:27:32 -05:00
Jarvis 07c87fba3a comms: homelab 20260719T232620Z 2026-07-19 18:26:20 -05:00
wjarvis mos-comms a57f0a7b1f comms: usc 20260719T232617Z 2026-07-19 18:26:18 -05:00
Jarvis d9988e089d comms: homelab 20260719T232234Z 2026-07-19 18:22:34 -05:00
wjarvis mos-comms 14ef7a7dbd comms: usc 20260719T232155Z 2026-07-19 18:21:55 -05:00
wjarvis mos-comms 26127498d8 from-mos: WI-5 #832 MERGED (07553ead) DONE + WI-6 continuation GO 2026-07-19 18:20:10 -05:00
wjarvis mos-comms 686a0da5b4 comms: usc 20260719T230722Z 2026-07-19 18:07:22 -05:00
Jarvis 75e0c1a3b2 comms: homelab 20260719T230628Z 2026-07-19 18:06:28 -05:00
Jarvis 783cbb5c33 comms: homelab 20260719T230619Z 2026-07-19 18:06:19 -05:00
wjarvis mos-comms 0dd56af57f from-mos: P5 FIRE PASS + admission ADMITTED — WI-5 #832 @e196abfd 2026-07-19 18:05:40 -05:00
Jarvis 898c24af73 comms: homelab 20260719T225851Z 2026-07-19 17:58:51 -05:00
wjarvis mos-comms b572b0198e comms: usc 20260719T225702Z 2026-07-19 17:57:02 -05:00
wjarvis mos-comms fb55ab11db comms: usc 20260719T225218Z 2026-07-19 17:52:19 -05:00
Jarvis d7e4660b7f comms: homelab 20260719T225211Z 2026-07-19 17:52:12 -05:00
wjarvis mos-comms 36a95ddefd from-mos: WI-5 #832 @e196abfd independent fix-verification + D4 durability pointer 2026-07-19 17:50:43 -05:00
wjarvis mos-comms 60484693d4 comms: usc 20260719T225025Z 2026-07-19 17:50:25 -05:00
wjarvis mos-comms 45bc8a6f42 comms: usc 20260719T224542Z 2026-07-19 17:45:42 -05:00
Jarvis 0e6e950964 comms: homelab 20260719T224445Z 2026-07-19 17:44:45 -05:00
wjarvis mos-comms dbf1a07dbb comms: usc 20260719T224426Z 2026-07-19 17:44:26 -05:00
wjarvis mos-comms 0a0c5eb11a comms: usc 20260719T223657Z 2026-07-19 17:36:57 -05:00
Jarvis 65a962f3ca comms: homelab 20260719T223524Z 2026-07-19 17:35:24 -05:00
wjarvis mos-comms ce618b8551 from-mos: WI-5 #832 @e5526270 ruling — CODE finding-1 REAL fail-open, revoke-first durability fix + D4 scope 2026-07-19 17:34:22 -05:00
Jarvis cb37b3a596 comms: homelab 20260719T223136Z 2026-07-19 17:31:36 -05:00
wjarvis mos-comms bfabe45937 comms: usc 20260719T223034Z 2026-07-19 17:30:34 -05:00
Jarvis 8686313724 comms: homelab 20260719T222632Z 2026-07-19 17:26:32 -05:00
wjarvis mos-comms ab33b6651a comms: usc 20260719T222527Z 2026-07-19 17:25:27 -05:00
wjarvis mos-comms 20707ef294 comms: usc 20260719T222031Z 2026-07-19 17:20:31 -05:00
Jarvis 7d5a53cf67 comms: homelab 20260719T222012Z 2026-07-19 17:20:12 -05:00
wjarvis mos-comms 6e36236e76 comms: usc 20260719T221840Z 2026-07-19 17:18:40 -05:00
wjarvis mos-comms 7d8b2d389c comms: usc 20260719T220555Z 2026-07-19 17:05:55 -05:00
wjarvis mos-comms fac718a624 comms: usc 20260719T220419Z 2026-07-19 17:04:19 -05:00
Jarvis 9cecf528f7 comms: homelab 20260719T220344Z 2026-07-19 17:03:45 -05:00
wjarvis mos-comms c17519403f from-mos: WI-5 #832 split-verdict ruling — B2 REAL HIGH + remediation scope 2026-07-19 17:02:16 -05:00
Jarvis 4bdd67b136 comms: homelab 20260719T214837Z 2026-07-19 16:48:38 -05:00
wjarvis mos-comms 2137fc3275 comms: usc 20260719T214754Z 2026-07-19 16:47:54 -05:00
Jarvis 0377711de2 comms: homelab 20260719T214634Z 2026-07-19 16:46:34 -05:00
wjarvis mos-comms d187649db1 comms: usc 20260719T214531Z 2026-07-19 16:45:31 -05:00
wjarvis mos-comms b344108b5d mos: ratify board-mirror preferred-default activation (homelab cross-host PASS) 2026-07-19 16:44:06 -05:00
Jarvis ddb7c26fe3 comms: homelab 20260719T213842Z 2026-07-19 16:38:42 -05:00
wjarvis mos-comms 8141835a2b comms: usc 20260719T213744Z 2026-07-19 16:37:45 -05:00
Jarvis e0c97683bd comms: homelab 20260719T213618Z 2026-07-19 16:36:19 -05:00
wjarvis mos-comms 2b3c12efc8 mos: board-hygiene decision + WI-5 build-landed verified + P5-fire sequencing 2026-07-19 16:35:41 -05:00
Jarvis 97fecb79f6 comms: homelab 20260719T213421Z 2026-07-19 16:34:21 -05:00
wjarvis mos-comms e57797b73f comms: usc 20260719T213331Z 2026-07-19 16:33:31 -05:00
Jarvis e68e34a4e3 comms: homelab 20260719T212700Z 2026-07-19 16:27:00 -05:00
wjarvis mos-comms fb56fe7aaa comms: usc 20260719T212536Z 2026-07-19 16:25:36 -05:00
wjarvis mos-comms 387894d353 comms: usc 20260719T212214Z 2026-07-19 16:22:15 -05:00
Jarvis 1905b79381 comms: homelab 20260719T211656Z 2026-07-19 16:16:57 -05:00
wjarvis mos-comms cea125f05b mos: WI-5 #832 sign-off — GO, Q1 confirmed, Q2 hybrid probe-form + provenance-form amendment 2026-07-19 16:16:03 -05:00
Jarvis df5558aeb7 comms: homelab 20260719T210930Z 2026-07-19 16:09:30 -05:00
wjarvis mos-comms 5db0d227a8 comms: usc 20260719T210735Z 2026-07-19 16:07:35 -05:00
wjarvis mos-comms b5ac1c5c4e mos: WI-4 RoR provenance verified + go-forward review-provenance SOP + WI-5 gate 2026-07-19 16:04:52 -05:00
wjarvis mos-comms fbc5b55030 comms: usc 20260719T205114Z 2026-07-19 15:51:14 -05:00
Jarvis c7b56fec5f comms: homelab 20260719T204747Z 2026-07-19 15:47:47 -05:00
wjarvis mos-comms b97dd03c8e mos: reconcile — WI-4 #831 MERGED, clear stale HOLD 2026-07-19 15:44:18 -05:00
Jarvis 6fb5a1c646 comms: homelab 20260719T202448Z 2026-07-19 15:24:48 -05:00
Jarvis e758732a18 comms: homelab 20260719T195240Z 2026-07-19 14:52:40 -05:00
wjarvis mos-comms 2e0723c028 comms: mos authority classification — probe-3 #827 attempt-3 3/3 FIRE PASS + immutable provenance (closes homelab 194643Z HOLD) 2026-07-19 14:49:56 -05:00
Jarvis af0a321266 comms: homelab 20260719T194643Z 2026-07-19 14:46:43 -05:00
Jarvis c7420797d8 comms: homelab 20260719T192322Z 2026-07-19 14:23:22 -05:00
wjarvis mos-comms c2f3e307f1 comms: usc 20260719T192026Z 2026-07-19 14:20:26 -05:00
Jarvis 9fd81cd75b comms: homelab 20260719T191427Z 2026-07-19 14:14:27 -05:00
wjarvis mos-comms 81080aa6d5 comms: usc 20260719T190719Z 2026-07-19 14:07:19 -05:00
Jarvis 8f652156e4 comms: homelab 20260719T183035Z 2026-07-19 13:30:36 -05:00
wjarvis mos-comms 21cb2bd6a4 comms: usc 20260719T182758Z 2026-07-19 13:27:58 -05:00
Jarvis 6f76a59dd4 comms: homelab 20260719T004001Z 2026-07-18 19:40:01 -05:00
wjarvis mos-comms cc0b767988 comms: usc 20260719T002605Z 2026-07-18 19:26:05 -05:00
Jarvis 5f016efb68 comms: homelab 20260719T001145Z 2026-07-18 19:11:46 -05:00
wjarvis mos-comms 861506188d comms: usc 20260718T235411Z 2026-07-18 18:54:11 -05:00
Jarvis 486951e933 comms: homelab 20260718T232411Z 2026-07-18 18:24:11 -05:00
wjarvis mos-comms 2f4ab7baf5 comms: usc 20260718T230001Z 2026-07-18 18:00:02 -05:00
Jarvis e405df4e24 comms: homelab 20260718T221944Z 2026-07-18 17:19:44 -05:00
wjarvis mos-comms c86d50e2ef comms: usc 20260718T220843Z 2026-07-18 17:08:43 -05:00
Jarvis 6a72eb2b78 comms: homelab 20260718T214846Z 2026-07-18 16:48:46 -05:00
Jarvis da6f1e22db comms: homelab 20260718T214711Z 2026-07-18 16:47:11 -05:00
Jarvis b4a15b7429 comms: homelab 20260718T213443Z 2026-07-18 16:34:43 -05:00
Jarvis 8538262c3e comms: homelab 20260718T210805Z 2026-07-18 16:08:05 -05:00
Jarvis 36d564e39c comms: homelab 20260718T201707Z 2026-07-18 15:17:07 -05:00
wjarvis mos-comms 760c293f45 comms: usc 20260718T182225Z 2026-07-18 13:22:25 -05:00
Jarvis 1c8d316d8e comms: homelab 20260718T182110Z 2026-07-18 13:21:10 -05:00
wjarvis mos-comms 898bbc92cf comms: usc 20260718T181938Z 2026-07-18 13:19:38 -05:00
wjarvis mos-comms 964f0b8549 comms: usc 20260718T181446Z 2026-07-18 13:14:46 -05:00
Jarvis 0d5aace51d comms: homelab 20260718T181314Z 2026-07-18 13:13:14 -05:00
wjarvis mos-comms 9aa0cad913 comms: usc 20260718T181108Z 2026-07-18 13:11:08 -05:00
wjarvis mos-comms d20218d792 comms: usc 20260718T181019Z 2026-07-18 13:10:19 -05:00
wjarvis mos-comms a259ae5fdf comms: usc 20260718T175601Z 2026-07-18 12:56:02 -05:00
Jarvis 9d0eb56209 comms: homelab 20260718T175244Z 2026-07-18 12:52:44 -05:00
wjarvis mos-comms 8c7981d398 comms: usc 20260718T175132Z 2026-07-18 12:51:33 -05:00
Jarvis 05a468792d comms: homelab 20260718T174935Z 2026-07-18 12:49:36 -05:00
wjarvis mos-comms ccde200b3c comms: usc 20260718T174526Z 2026-07-18 12:45:27 -05:00
wjarvis mos-comms 4ffb49fd8c comms: usc 20260718T073055Z 2026-07-18 02:30:55 -05:00
Jarvis 927ac8a252 comms: homelab 20260718T072900Z 2026-07-18 02:29:00 -05:00
wjarvis mos-comms ca1dac0a26 comms: usc 20260718T072715Z 2026-07-18 02:27:15 -05:00
wjarvis mos-comms 2ca3c339d2 comms: usc 20260718T071956Z 2026-07-18 02:19:56 -05:00
Jarvis 6f9310cf65 comms: homelab 20260718T071744Z 2026-07-18 02:17:44 -05:00
wjarvis mos-comms c7b8b0fa77 comms: usc 20260718T071509Z 2026-07-18 02:15:09 -05:00
wjarvis mos-comms 7adda4ca1d comms: usc 20260718T071346Z 2026-07-18 02:13:47 -05:00
Jarvis d6f148602e comms: homelab 20260718T071232Z 2026-07-18 02:12:32 -05:00
wjarvis mos-comms fc9a6c9c68 comms: usc 20260718T071023Z 2026-07-18 02:10:23 -05:00
Jarvis afa733a4e4 comms: homelab 20260718T070900Z 2026-07-18 02:09:00 -05:00
wjarvis mos-comms fbf107d616 comms: usc 20260718T064633Z 2026-07-18 01:46:34 -05:00
Jarvis e84d296c25 comms: homelab 20260718T064558Z 2026-07-18 01:45:58 -05:00
wjarvis mos-comms be361fc4d6 comms: usc 20260718T064503Z 2026-07-18 01:45:03 -05:00
wjarvis mos-comms 5eda43dfe5 comms: usc 20260718T064236Z 2026-07-18 01:42:37 -05:00
Jarvis aea273d88f comms: homelab 20260718T064050Z 2026-07-18 01:40:50 -05:00
wjarvis mos-comms d822e98439 comms: usc 20260718T064014Z 2026-07-18 01:40:14 -05:00
wjarvis mos-comms 3c4b840da6 comms: usc 20260718T063801Z 2026-07-18 01:38:01 -05:00
Jarvis 78416ac9c5 comms: homelab 20260718T063456Z 2026-07-18 01:34:57 -05:00
wjarvis mos-comms 013a219e24 comms: usc 20260718T063352Z 2026-07-18 01:33:53 -05:00
Jarvis 6db064e61a comms: homelab 20260718T063227Z 2026-07-18 01:32:27 -05:00
wjarvis mos-comms 65c5df6c18 comms: usc 20260718T063120Z 2026-07-18 01:31:20 -05:00
Jarvis 29c5c91c10 comms: homelab 20260718T062614Z 2026-07-18 01:26:14 -05:00
wjarvis mos-comms 4e001da0b9 comms: usc 20260718T061322Z 2026-07-18 01:13:24 -05:00
Jarvis 253fb728f8 comms: homelab 20260718T060542Z 2026-07-18 01:05:42 -05:00
Jarvis 5ab1c235e5 comms: homelab 20260718T053804Z 2026-07-18 00:38:04 -05:00
Jarvis aaa7e766f0 comms: homelab 20260718T052654Z 2026-07-18 00:26:54 -05:00
Jarvis 2b8eaff5ff comms: homelab 20260718T045849Z 2026-07-17 23:58:49 -05:00
Jarvis e9f117a548 comms: homelab 20260718T045054Z 2026-07-17 23:50:54 -05:00
Jarvis ef51268273 comms: homelab 20260718T044708Z 2026-07-17 23:47:08 -05:00
Jarvis bac0b878b9 comms: homelab 20260718T044018Z 2026-07-17 23:40:19 -05:00
Jarvis c3309c7785 comms: homelab 20260718T042932Z 2026-07-17 23:29:32 -05:00
Jarvis afa01539c9 comms: homelab 20260718T040919Z 2026-07-17 23:09:19 -05:00
Jarvis 3eeb019523 comms: homelab 20260718T035607Z 2026-07-17 22:56:07 -05:00
Jarvis 9446c28f2b comms: homelab 20260718T035335Z 2026-07-17 22:53:35 -05:00
Jarvis 543043f957 comms: homelab 20260718T030917Z 2026-07-17 22:09:17 -05:00
Jarvis b6757f5bf8 comms: homelab 20260718T023435Z 2026-07-17 21:34:35 -05:00
Jarvis 42ac1763f0 comms: homelab 20260718T020446Z 2026-07-17 21:04:46 -05:00
Jarvis 7a5ae9aa7c comms: homelab 20260718T014156Z 2026-07-17 20:41:56 -05:00
Jarvis 7b61dbfa8e comms: homelab 20260718T011455Z 2026-07-17 20:14:55 -05:00
Jarvis c9d93774b1 comms: homelab 20260718T005402Z 2026-07-17 19:54:02 -05:00
Jarvis 058b2afb24 comms: homelab 20260718T000626Z 2026-07-17 19:06:26 -05:00
Jarvis f2f1a59e64 comms: homelab 20260717T235313Z 2026-07-17 18:53:13 -05:00
wjarvis mos-comms 821054d020 comms: usc 20260717T234250Z 2026-07-17 18:42:51 -05:00
Jarvis 40aebd61b2 comms: homelab 20260717T234135Z 2026-07-17 18:41:35 -05:00
Jarvis 356f6b1fce comms: homelab 20260717T225935Z 2026-07-17 17:59:36 -05:00
wjarvis mos-comms 9d6ad07909 comms: usc 20260717T225825Z 2026-07-17 17:58:26 -05:00
Jarvis 55bcf189ee comms: homelab 20260717T225727Z 2026-07-17 17:57:27 -05:00
wjarvis mos-comms be3d510547 comms: usc 20260717T224339Z 2026-07-17 17:43:40 -05:00
Jarvis 90e092dc7e comms: homelab 20260717T224247Z 2026-07-17 17:42:47 -05:00
wjarvis mos-comms 12adf45c78 comms: usc 20260717T222134Z 2026-07-17 17:21:34 -05:00
Jarvis 91ac33b906 comms: homelab 20260717T222050Z 2026-07-17 17:20:50 -05:00
wjarvis mos-comms 4eba8f07de comms: usc 20260717T221821Z 2026-07-17 17:18:22 -05:00
Jarvis d7f2867a06 comms: homelab 20260717T221752Z 2026-07-17 17:17:52 -05:00
Jarvis 384b50d209 comms: homelab 20260717T221525Z 2026-07-17 17:15:25 -05:00
wjarvis mos-comms 1fc0a34506 comms: usc 20260717T220541Z 2026-07-17 17:05:42 -05:00
Jarvis b7169010ee comms: homelab 20260717T220152Z 2026-07-17 17:01:52 -05:00
wjarvis mos-comms 058bdbc156 comms: usc 20260717T215955Z 2026-07-17 16:59:55 -05:00
Jarvis b9aa48c81d comms: homelab 20260717T215748Z 2026-07-17 16:57:48 -05:00
Jarvis fb40b53697 comms: homelab 20260717T201740Z 2026-07-17 15:17:40 -05:00
wjarvis mos-comms 2470e02b6f comms: usc 20260717T201631Z 2026-07-17 15:16:32 -05:00
Jarvis f67e771ede comms: homelab 20260717T201500Z 2026-07-17 15:15:00 -05:00
wjarvis mos-comms caa7350d03 comms: usc 20260717T201213Z 2026-07-17 15:12:14 -05:00
Jarvis 4534a35ead comms: homelab 20260717T201053Z 2026-07-17 15:10:53 -05:00
wjarvis mos-comms acb02cd351 comms: usc 20260717T200544Z 2026-07-17 15:05:44 -05:00
wjarvis mos-comms e3f6c7e39f comms: usc 20260717T200420Z 2026-07-17 15:04:20 -05:00
Jarvis 6111fc2027 comms: homelab 20260717T200418Z 2026-07-17 15:04:18 -05:00
wjarvis mos-comms 984551eeff comms: usc 20260717T200410Z 2026-07-17 15:04:10 -05:00
wjarvis mos-comms 603cc636ec comms: usc 20260717T200404Z 2026-07-17 15:04:04 -05:00
Jarvis 8ec60a1c2e comms: homelab 20260717T200310Z 2026-07-17 15:03:10 -05:00
wjarvis mos-comms 850afb0a8a comms: usc 20260717T200130Z 2026-07-17 15:01:30 -05:00
Jarvis 2f8f5f95db comms: homelab 20260717T195938Z 2026-07-17 14:59:38 -05:00
wjarvis mos-comms 34dd741278 comms: usc 20260717T195505Z 2026-07-17 14:55:05 -05:00
Jarvis 80989299b9 comms: homelab 20260717T195346Z 2026-07-17 14:53:47 -05:00
wjarvis mos-comms a62046c782 comms: usc 20260717T194706Z 2026-07-17 14:47:06 -05:00
wjarvis mos-comms dfee300012 comms: usc 20260717T194523Z 2026-07-17 14:45:24 -05:00
Jarvis ab4db7163f comms: homelab 20260717T194510Z 2026-07-17 14:45:10 -05:00
wjarvis mos-comms 7dd5a145fc comms: usc 20260717T194444Z 2026-07-17 14:44:45 -05:00
Jarvis 49ded73049 comms: homelab 20260717T194423Z 2026-07-17 14:44:24 -05:00
wjarvis mos-comms 4760d30688 comms: usc 20260717T194219Z 2026-07-17 14:42:19 -05:00
wjarvis mos-comms 48531ca692 comms: usc 20260717T194123Z 2026-07-17 14:41:24 -05:00
Jarvis 4f5fe98f40 comms: homelab 20260717T193612Z 2026-07-17 14:36:13 -05:00
wjarvis mos-comms 7b1396d602 comms: usc 20260717T192217Z 2026-07-17 14:22:18 -05:00
Jarvis 23db3e3b67 comms: homelab 20260717T191941Z 2026-07-17 14:19:41 -05:00
wjarvis mos-comms 70826ef610 comms: usc 20260717T191844Z 2026-07-17 14:18:44 -05:00
Jarvis 7a51f665cd comms: homelab 20260717T191805Z 2026-07-17 14:18:05 -05:00
wjarvis mos-comms 1487e58766 comms: usc 20260717T191404Z 2026-07-17 14:14:04 -05:00
Jarvis 33e497b587 comms: homelab 20260717T191310Z 2026-07-17 14:13:10 -05:00
wjarvis mos-comms 44197766ed comms: usc 20260717T191137Z 2026-07-17 14:11:37 -05:00
Jarvis 5686e656b4 comms: homelab 20260717T190730Z 2026-07-17 14:07:31 -05:00
wjarvis mos-comms 6221cd3899 comms: usc 20260717T185423Z 2026-07-17 13:54:23 -05:00
wjarvis mos-comms adc3d2b1e6 comms: usc 20260717T184343Z 2026-07-17 13:43:43 -05:00
wjarvis mos-comms 058a6aaa18 comms: usc 20260717T184244Z 2026-07-17 13:42:44 -05:00
Jarvis c91639e09f comms: homelab 20260717T184100Z 2026-07-17 13:41:00 -05:00
wjarvis mos-comms 025798b823 comms: usc 20260717T183700Z 2026-07-17 13:37:01 -05:00
wjarvis mos-comms b6fbbc0d58 comms: usc 20260717T182756Z 2026-07-17 13:27:56 -05:00
Jarvis 0f932f3035 comms: homelab 20260717T182340Z 2026-07-17 13:23:40 -05:00
wjarvis mos-comms b081ba4df4 comms: usc 20260717T181654Z 2026-07-17 13:16:55 -05:00
wjarvis mos-comms d3481f026b comms: usc 20260717T180959Z 2026-07-17 13:09:59 -05:00
Jarvis 4e6ddd59ef comms: homelab 20260717T180506Z 2026-07-17 13:05:06 -05:00
wjarvis mos-comms f00705b026 comms: usc 20260717T175924Z 2026-07-17 12:59:25 -05:00
Jarvis 1e4a15db59 comms: homelab 20260717T175901Z 2026-07-17 12:59:01 -05:00
Jarvis 37b6603d33 comms: homelab 20260717T175828Z 2026-07-17 12:58:28 -05:00
wjarvis mos-comms 50f8a2a59f comms: usc 20260717T175638Z 2026-07-17 12:56:38 -05:00
Jarvis a99d429f34 comms: homelab 20260717T175222Z 2026-07-17 12:52:23 -05:00
wjarvis mos-comms 6deb5eb7f8 comms: usc 20260717T174634Z 2026-07-17 12:46:34 -05:00
Jarvis d168f54dfd comms: homelab 20260717T173859Z 2026-07-17 12:38:59 -05:00
Jarvis 1a03d60287 comms: homelab 20260717T172938Z 2026-07-17 12:29:38 -05:00
wjarvis mos-comms b2958bbed7 comms: usc 20260717T172744Z 2026-07-17 12:27:44 -05:00
wjarvis mos-comms 8c3bb2f3a5 comms: usc 20260717T172245Z 2026-07-17 12:22:46 -05:00
Jarvis e9f015ace4 comms: homelab 20260717T172124Z 2026-07-17 12:21:25 -05:00
wjarvis mos-comms 0a94e37dbe comms: usc 20260717T171933Z 2026-07-17 12:19:33 -05:00
wjarvis mos-comms 719862de83 comms: usc 20260717T171452Z 2026-07-17 12:14:52 -05:00
Jarvis 22bdaac379 comms: homelab 20260717T171446Z 2026-07-17 12:14:46 -05:00
wjarvis mos-comms 912ad391ff comms: usc 20260717T171346Z 2026-07-17 12:13:46 -05:00
Jarvis ea73655e04 comms: homelab 20260717T171301Z 2026-07-17 12:13:01 -05:00
wjarvis mos-comms 22c1752c30 comms: usc 20260717T171127Z 2026-07-17 12:11:27 -05:00
Jarvis b298a4a7ad comms: homelab 20260717T171059Z 2026-07-17 12:11:00 -05:00
wjarvis mos-comms a8edd975a9 comms: usc 20260717T171030Z 2026-07-17 12:10:30 -05:00
wjarvis mos-comms 64d6b982f9 comms: usc 20260717T171010Z 2026-07-17 12:10:10 -05:00
Jarvis 76c34e54d3 comms: homelab 20260717T170945Z 2026-07-17 12:09:45 -05:00
wjarvis mos-comms 47a865f943 comms: usc 20260717T170903Z 2026-07-17 12:09:03 -05:00
wjarvis mos-comms d7fad74595 comms: usc 20260717T170818Z 2026-07-17 12:08:18 -05:00
Jarvis dab8657af1 comms: homelab 20260717T170329Z 2026-07-17 12:03:30 -05:00
wjarvis mos-comms c116447631 comms: usc 20260717T170211Z 2026-07-17 12:02:11 -05:00
Jarvis 3d4fa20adf comms: homelab 20260717T165952Z 2026-07-17 11:59:52 -05:00
wjarvis mos-comms 4466e2d82c comms: usc 20260717T165845Z 2026-07-17 11:58:45 -05:00
Jarvis ab0e15afb2 comms: homelab 20260717T165710Z 2026-07-17 11:57:10 -05:00
wjarvis mos-comms 5cbda2df31 comms: usc 20260717T165422Z 2026-07-17 11:54:23 -05:00
Jarvis 3259aef924 comms: homelab 20260717T164743Z 2026-07-17 11:47:43 -05:00
Jarvis 21766447d7 comms: homelab 20260717T163919Z 2026-07-17 11:39:19 -05:00
Jarvis 924cbd7f4b comms: homelab 20260717T163848Z 2026-07-17 11:38:49 -05:00
wjarvis mos-comms ea12623026 comms: usc 20260717T163703Z 2026-07-17 11:37:05 -05:00
Jarvis df91a4e888 comms: homelab 20260717T163226Z 2026-07-17 11:32:26 -05:00
wjarvis mos-comms bd68956622 comms: usc 20260717T163131Z 2026-07-17 11:31:31 -05:00
Jarvis 8e6939f321 comms: homelab 20260717T163055Z 2026-07-17 11:30:56 -05:00
wjarvis mos-comms 693fd9aaa0 comms: usc 20260717T162912Z 2026-07-17 11:29:13 -05:00
Jarvis a064859482 comms: homelab 20260717T162537Z 2026-07-17 11:25:37 -05:00
wjarvis mos-comms f631a3c76a comms: usc 20260717T162402Z 2026-07-17 11:24:03 -05:00
Jarvis d9bd8fab26 comms: homelab 20260717T161951Z 2026-07-17 11:19:52 -05:00
wjarvis mos-comms 4d0ba9fe49 comms: usc 20260717T161358Z 2026-07-17 11:13:58 -05:00
Jarvis 8cc3460b93 comms: homelab 20260717T054411Z 2026-07-17 00:44:11 -05:00
Jarvis 5a62453e1f comms: homelab 20260717T054002Z 2026-07-17 00:40:02 -05:00
Jarvis afa325c0e5 comms: homelab 20260717T051302Z 2026-07-17 00:13:02 -05:00
Jarvis cc2ea4d78e comms: homelab 20260717T050342Z 2026-07-17 00:03:42 -05:00
Jarvis a4573475ca comms: homelab 20260717T045530Z 2026-07-16 23:55:30 -05:00
Jarvis 9d238f7009 comms: homelab 20260717T044622Z 2026-07-16 23:46:22 -05:00
Jarvis 84088ee3c8 comms: homelab 20260717T043324Z 2026-07-16 23:33:24 -05:00
Jarvis 558a6af7e8 comms: homelab 20260717T042528Z 2026-07-16 23:25:28 -05:00
Jarvis 6ff8983230 comms: homelab 20260717T040522Z 2026-07-16 23:05:22 -05:00
Jarvis 838a90b51e comms: homelab 20260717T034955Z 2026-07-16 22:49:55 -05:00
Jarvis d28f4ba980 comms: homelab 20260717T034658Z 2026-07-16 22:46:59 -05:00
wjarvis mos-comms a93b168522 comms: usc 20260717T034229Z 2026-07-16 22:42:29 -05:00
Jarvis 8e402b0827 comms: homelab 20260717T034041Z 2026-07-16 22:40:41 -05:00
wjarvis mos-comms 6599ae33b9 comms: usc 20260717T032630Z 2026-07-16 22:26:30 -05:00
wjarvis mos-comms c5306ed7a5 comms: usc 20260717T031936Z 2026-07-16 22:19:36 -05:00
Jarvis cd0a0b223d comms: homelab 20260717T024629Z 2026-07-16 21:46:29 -05:00
wjarvis mos-comms 954bc46de7 comms: usc 20260717T023738Z 2026-07-16 21:37:38 -05:00
Jarvis 8b3297c3e8 comms: homelab 20260717T023645Z 2026-07-16 21:36:45 -05:00
Jarvis 8fae9b12fb comms: homelab 20260717T021019Z 2026-07-16 21:10:19 -05:00
Jarvis a81005672a comms: homelab 20260717T020606Z 2026-07-16 21:06:06 -05:00
Jarvis bff8c49253 comms: homelab 20260717T011533Z 2026-07-16 20:15:33 -05:00
Jarvis fd924d30ba comms: homelab 20260717T010556Z 2026-07-16 20:05:56 -05:00
wjarvis mos-comms 9b332f1a8d comms: usc 20260717T010236Z 2026-07-16 20:02:36 -05:00
Jarvis ea4fd1b65b comms: homelab 20260717T010120Z 2026-07-16 20:01:20 -05:00
Jarvis ca66ffaa33 comms: homelab 20260717T005321Z 2026-07-16 19:53:21 -05:00
wjarvis mos-comms 58a65886a9 comms: usc 20260717T005310Z 2026-07-16 19:53:10 -05:00
Jarvis 81aaf995ae comms: homelab 20260717T005121Z 2026-07-16 19:51:21 -05:00
wjarvis mos-comms 1539b6f5bf comms: usc 20260717T005048Z 2026-07-16 19:50:48 -05:00
Jarvis 6288aade27 comms: homelab 20260717T005007Z 2026-07-16 19:50:07 -05:00
wjarvis mos-comms b1d248c2a0 comms: usc 20260717T004800Z 2026-07-16 19:48:00 -05:00
Jarvis 7d334c0e60 comms: homelab 20260717T004659Z 2026-07-16 19:46:59 -05:00
wjarvis mos-comms 0a700445a3 comms: usc 20260717T004423Z 2026-07-16 19:44:23 -05:00
Jarvis e52ef3d3c6 comms: homelab 20260717T003948Z 2026-07-16 19:39:49 -05:00
wjarvis mos-comms 5c9af05691 comms: usc 20260717T003558Z 2026-07-16 19:35:58 -05:00
Jarvis 2aa669a026 comms: homelab 20260717T003303Z 2026-07-16 19:33:03 -05:00
wjarvis mos-comms 03bd84db1d comms: usc 20260717T002517Z 2026-07-16 19:25:17 -05:00
Jarvis bce06f9336 comms: homelab 20260717T002045Z 2026-07-16 19:20:45 -05:00
wjarvis mos-comms 267fc9a787 comms: usc 20260717T001955Z 2026-07-16 19:19:56 -05:00
Jarvis 7652bc7f3d comms: homelab 20260717T001825Z 2026-07-16 19:18:25 -05:00
wjarvis mos-comms e7e1c486e9 comms: usc 20260717T001737Z 2026-07-16 19:17:38 -05:00
Jarvis 6efb95ed07 comms: homelab 20260717T001619Z 2026-07-16 19:16:19 -05:00
wjarvis mos-comms 5a6ef27481 comms: usc 20260717T001525Z 2026-07-16 19:15:25 -05:00
Jarvis bf2adbf5a2 comms: homelab 20260717T000921Z 2026-07-16 19:09:21 -05:00
Jarvis 33236774d1 comms: homelab 20260717T000614Z 2026-07-16 19:06:14 -05:00
wjarvis mos-comms c77e4c9e35 comms: usc 20260717T000444Z 2026-07-16 19:04:44 -05:00
wjarvis mos-comms a7920439a7 comms: usc 20260717T000144Z 2026-07-16 19:01:45 -05:00
Jarvis 958bc7d369 comms: homelab 20260716T235901Z 2026-07-16 18:59:01 -05:00
wjarvis mos-comms 3b24bb8c62 comms: usc 20260716T232048Z 2026-07-16 18:20:49 -05:00
Jarvis 5e443f5a0c comms: homelab 20260716T231934Z 2026-07-16 18:19:34 -05:00
wjarvis mos-comms 93dc894e69 comms: usc 20260716T231223Z 2026-07-16 18:12:23 -05:00
Jarvis 597b4e4322 comms: homelab 20260716T231157Z 2026-07-16 18:11:58 -05:00
wjarvis mos-comms a58f9d58d7 comms: usc 20260716T230921Z 2026-07-16 18:09:21 -05:00
Jarvis 0f2852a4ed comms: homelab 20260716T230349Z 2026-07-16 18:03:50 -05:00
wjarvis mos-comms 84310c81f4 comms: usc 20260716T230203Z 2026-07-16 18:02:03 -05:00
Jarvis 412c03468f comms: homelab 20260716T225914Z 2026-07-16 17:59:14 -05:00
wjarvis mos-comms a2fcefa35d comms: usc 20260716T225854Z 2026-07-16 17:58:54 -05:00
wjarvis mos-comms c29208fda1 comms: usc 20260716T225547Z 2026-07-16 17:55:48 -05:00
Jarvis 65ed453443 comms: homelab 20260716T225411Z 2026-07-16 17:54:11 -05:00
wjarvis mos-comms 15cca9db1c comms: usc 20260716T224351Z 2026-07-16 17:43:52 -05:00
Jarvis 1d1fd17a33 comms: homelab 20260716T224154Z 2026-07-16 17:41:54 -05:00
Jarvis 80a4096f75 comms: homelab 20260716T222617Z 2026-07-16 17:26:17 -05:00
wjarvis mos-comms 87dae08286 comms: usc 20260716T222138Z 2026-07-16 17:21:38 -05:00
wjarvis mos-comms a6de7801ff comms: usc 20260716T221912Z 2026-07-16 17:19:12 -05:00
Jarvis af70995755 comms: homelab 20260716T221852Z 2026-07-16 17:18:53 -05:00
wjarvis mos-comms e37abcfe77 comms: usc 20260716T221638Z 2026-07-16 17:16:38 -05:00
Jarvis 860ac0dfb1 comms: homelab 20260716T221035Z 2026-07-16 17:10:36 -05:00
Jarvis 3b84dd4d1e comms: homelab 20260716T220558Z 2026-07-16 17:05:58 -05:00
wjarvis mos-comms 69f1ea0fb4 comms: usc 20260716T220445Z 2026-07-16 17:04:45 -05:00
wjarvis mos-comms 490aa76415 comms: usc 20260716T215656Z 2026-07-16 16:56:56 -05:00
wjarvis mos-comms 05edd269a8 comms: usc 20260716T213201Z 2026-07-16 16:32:02 -05:00
Jarvis c719228d39 comms: homelab 20260716T213036Z 2026-07-16 16:30:36 -05:00
wjarvis mos-comms 53d0a9ba93 comms: usc 20260716T212948Z 2026-07-16 16:29:48 -05:00
Jarvis a8baa9f96b comms: homelab 20260716T212605Z 2026-07-16 16:26:05 -05:00
wjarvis mos-comms 8b3d937899 comms: usc 20260716T212356Z 2026-07-16 16:23:57 -05:00
Jarvis 1d43d35c47 comms: homelab 20260716T212201Z 2026-07-16 16:22:01 -05:00
wjarvis mos-comms 40413fb9c7 comms: usc 20260716T211842Z 2026-07-16 16:18:43 -05:00
Jarvis 77d5cd0ccb comms: homelab 20260716T210741Z 2026-07-16 16:07:41 -05:00
wjarvis mos-comms 91ed0d035a comms: usc 20260716T210612Z 2026-07-16 16:06:12 -05:00
wjarvis mos-comms 218da96b55 comms: usc 20260716T210540Z 2026-07-16 16:05:40 -05:00
Jarvis a4fe329ffc comms: homelab 20260716T210300Z 2026-07-16 16:03:00 -05:00
Jarvis 45da900080 comms: homelab 20260716T202942Z 2026-07-16 15:29:43 -05:00
Jarvis bd38ce9a61 comms: homelab 20260716T201301Z 2026-07-16 15:13:01 -05:00
wjarvis mos-comms 3a12be5e07 comms: usc 20260716T200756Z 2026-07-16 15:07:57 -05:00
Jarvis fcff1e6a19 comms: homelab 20260716T200217Z 2026-07-16 15:02:17 -05:00
Jarvis c46901d713 comms: homelab 20260716T193700Z 2026-07-16 14:37:00 -05:00
Jarvis 5b4615c9ea comms: homelab 20260716T191954Z 2026-07-16 14:19:54 -05:00
Jarvis 868458b5aa comms: homelab 20260716T185400Z 2026-07-16 13:54:00 -05:00
Jarvis 86dc2d69e3 comms: homelab 20260716T184048Z 2026-07-16 13:40:48 -05:00
Jarvis ef88212328 comms: homelab 20260716T182238Z 2026-07-16 13:22:39 -05:00
Jarvis 3e59e12316 comms: homelab 20260716T180558Z 2026-07-16 13:05:58 -05:00
Jarvis 570e27035f comms: homelab 20260716T173841Z 2026-07-16 12:38:41 -05:00
Jarvis 40bc9a0980 comms: homelab 20260716T170145Z 2026-07-16 12:01:45 -05:00
Jarvis 77c5e0e3be comms: homelab 20260716T164456Z 2026-07-16 11:44:56 -05:00
Jarvis b482f882c2 comms: homelab 20260716T163616Z 2026-07-16 11:36:16 -05:00
Jarvis a008e25308 comms: homelab 20260716T162531Z 2026-07-16 11:25:31 -05:00
Jarvis cd30f8c66d comms: homelab 20260716T160753Z 2026-07-16 11:07:53 -05:00
Jarvis 36a87a51e4 comms: homelab 20260716T160521Z 2026-07-16 11:05:21 -05:00
Jarvis 74ad5848d0 comms: homelab 20260716T155603Z 2026-07-16 10:56:03 -05:00
Jarvis 49cfe1604f comms: homelab 20260716T152713Z 2026-07-16 10:27:14 -05:00
Jarvis 77158132de comms: homelab 20260716T151028Z 2026-07-16 10:10:28 -05:00
Jarvis a66459c1ef comms: homelab 20260716T144847Z 2026-07-16 09:48:47 -05:00
Jarvis 382b59bfab comms: homelab 20260716T143547Z 2026-07-16 09:35:47 -05:00
Jarvis eb663d6b3c comms: homelab 20260716T133525Z 2026-07-16 08:35:26 -05:00
wjarvis mos-comms 7508070be1 comms: usc 20260716T132814Z 2026-07-16 08:28:14 -05:00
Jarvis 72ce456046 comms: homelab 20260716T132635Z 2026-07-16 08:26:35 -05:00
Jarvis 13e83821a2 comms: homelab 20260716T122032Z 2026-07-16 07:20:32 -05:00
Jarvis 7a5f499b25 comms: homelab 20260716T114624Z 2026-07-16 06:46:24 -05:00
Jarvis bf71e86084 comms: homelab 20260716T112046Z 2026-07-16 06:20:46 -05:00
Jarvis 07a4c8f9df comms: homelab 20260716T105629Z 2026-07-16 05:56:29 -05:00
Jarvis a474f447bf comms: homelab 20260716T103837Z 2026-07-16 05:38:37 -05:00
Jarvis 22b85eb2cd comms: homelab 20260716T095940Z 2026-07-16 04:59:40 -05:00
Jarvis ec9ba6e18b comms: homelab 20260716T091416Z 2026-07-16 04:14:16 -05:00
Jarvis 093e2e9815 comms: homelab 20260716T081621Z 2026-07-16 03:16:21 -05:00
Jarvis bbf367bd28 comms: homelab 20260716T074951Z 2026-07-16 02:49:51 -05:00
Jarvis e28041ce03 comms: homelab 20260716T073343Z 2026-07-16 02:33:43 -05:00
Jarvis 8ce238379c comms: homelab 20260716T072115Z 2026-07-16 02:21:15 -05:00
Jarvis c5dea41cd1 comms: homelab 20260716T065305Z 2026-07-16 01:53:05 -05:00
Jarvis 31604d7066 comms: homelab 20260716T064713Z 2026-07-16 01:47:13 -05:00
Jarvis dc6df621d7 comms: homelab 20260716T062711Z 2026-07-16 01:27:11 -05:00
Jarvis 0e5740c2c4 comms: homelab 20260716T062300Z 2026-07-16 01:23:00 -05:00
Jarvis f847211bc7 comms: homelab 20260716T061207Z 2026-07-16 01:12:07 -05:00
Jarvis e2b9c7c588 comms: homelab 20260716T060157Z 2026-07-16 01:01:57 -05:00
Jarvis f4028c5608 comms: homelab 20260716T055721Z 2026-07-16 00:57:21 -05:00
Jarvis 24a503c4d6 comms: homelab 20260716T054839Z 2026-07-16 00:48:39 -05:00
Jarvis 29b0954170 comms: homelab 20260716T053646Z 2026-07-16 00:36:46 -05:00
Jarvis 450e4b25de comms: homelab 20260716T051703Z 2026-07-16 00:17:03 -05:00
Jarvis b9788d79b0 comms: homelab 20260716T043841Z 2026-07-15 23:38:41 -05:00
Jarvis af08bb6ed7 comms: homelab 20260716T043609Z 2026-07-15 23:36:09 -05:00
Jarvis ff09d723c2 comms: homelab 20260716T040256Z 2026-07-15 23:02:56 -05:00
Jarvis 08b6f68bbe comms: homelab 20260716T034008Z 2026-07-15 22:40:08 -05:00
Jarvis 39b944a7d9 comms: homelab 20260716T030959Z 2026-07-15 22:10:00 -05:00
Jarvis ccf872c28b comms: homelab 20260716T022655Z 2026-07-15 21:26:55 -05:00
Jarvis f5fc61dafa comms: homelab 20260716T020202Z 2026-07-15 21:02:02 -05:00
Jarvis 9a60e43f89 comms: homelab 20260716T013435Z 2026-07-15 20:34:35 -05:00
wjarvis mos-comms 8ba5e5c5d4 comms: usc 20260716T004701Z 2026-07-15 19:47:01 -05:00
Jarvis a896d0d072 comms: homelab 20260716T004626Z 2026-07-15 19:46:26 -05:00
wjarvis mos-comms 1cf1c1eef1 comms: usc 20260716T004019Z 2026-07-15 19:40:19 -05:00
Jarvis 255bf5c33e comms: homelab 20260716T003919Z 2026-07-15 19:39:19 -05:00
wjarvis mos-comms c5c24cf200 comms: usc 20260716T003657Z 2026-07-15 19:36:57 -05:00
wjarvis mos-comms 8bce99c875 comms: usc 20260716T003556Z 2026-07-15 19:35:56 -05:00
Jarvis d19c6feaed comms: homelab 20260716T003508Z 2026-07-15 19:35:08 -05:00
Jarvis c5be9e793a comms: homelab 20260716T002830Z 2026-07-15 19:28:30 -05:00
Jarvis 73010662a0 comms: homelab 20260716T002621Z 2026-07-15 19:26:21 -05:00
wjarvis mos-comms b15d537da3 comms: usc 20260716T002224Z 2026-07-15 19:22:24 -05:00
Jarvis 4d489dc20f comms: homelab 20260716T002015Z 2026-07-15 19:20:15 -05:00
wjarvis mos-comms 29f1d2810f comms: usc 20260716T001525Z 2026-07-15 19:15:25 -05:00
wjarvis mos-comms c36cdcd6f1 comms: usc 20260716T001310Z 2026-07-15 19:13:10 -05:00
wjarvis mos-comms 650dd65417 comms: usc 20260716T001135Z 2026-07-15 19:11:35 -05:00
Jarvis 49bfc434e1 comms: homelab 20260716T001001Z 2026-07-15 19:10:01 -05:00
wjarvis mos-comms f030641c4d comms: usc 20260716T000228Z 2026-07-15 19:02:28 -05:00
wjarvis mos-comms e1b5deab21 comms: usc 20260715T235819Z 2026-07-15 18:58:19 -05:00
wjarvis mos-comms 1e0fed09e0 comms: usc 20260715T235710Z 2026-07-15 18:57:10 -05:00
Jarvis e4b53c5fed comms: homelab 20260715T235559Z 2026-07-15 18:55:59 -05:00
wjarvis mos-comms b1958f6c2d comms: usc 20260715T234940Z 2026-07-15 18:49:40 -05:00
wjarvis mos-comms 278b08137c comms: usc 20260715T234642Z 2026-07-15 18:46:43 -05:00
wjarvis mos-comms fb2ef45f7a comms: usc 20260715T234546Z 2026-07-15 18:45:46 -05:00
Jarvis a6b8c3ad94 comms: homelab 20260715T234419Z 2026-07-15 18:44:19 -05:00
wjarvis mos-comms 79f21964f2 comms: usc 20260715T233559Z 2026-07-15 18:35:59 -05:00
wjarvis mos-comms 3c54c7773d comms: usc 20260715T233323Z 2026-07-15 18:33:23 -05:00
wjarvis mos-comms 413a704609 comms: usc 20260715T233234Z 2026-07-15 18:32:34 -05:00
Jarvis f93996af39 comms: homelab 20260715T233102Z 2026-07-15 18:31:03 -05:00
wjarvis mos-comms cc404ad8d4 comms: usc 20260715T232146Z 2026-07-15 18:21:46 -05:00
wjarvis mos-comms a05dfcb360 comms: usc 20260715T231907Z 2026-07-15 18:19:07 -05:00
wjarvis mos-comms c59b3fa53d comms: usc 20260715T231817Z 2026-07-15 18:18:17 -05:00
Jarvis c249d9e172 comms: homelab 20260715T231733Z 2026-07-15 18:17:34 -05:00
wjarvis mos-comms 91202653e4 comms: usc 20260715T230454Z 2026-07-15 18:04:54 -05:00
wjarvis mos-comms a662901ff0 comms: usc 20260715T230253Z 2026-07-15 18:02:53 -05:00
wjarvis mos-comms b72c19f87d comms: usc 20260715T230159Z 2026-07-15 18:01:59 -05:00
Jarvis 0a696ec2b3 comms: homelab 20260715T230102Z 2026-07-15 18:01:03 -05:00
wjarvis mos-comms 8e3f973905 comms: usc 20260715T224627Z 2026-07-15 17:46:27 -05:00
wjarvis mos-comms 66bdffab81 comms: usc 20260715T224257Z 2026-07-15 17:42:57 -05:00
wjarvis mos-comms e49fc9d782 comms: usc 20260715T224145Z 2026-07-15 17:41:45 -05:00
Jarvis 52870b3e0e comms: homelab 20260715T224054Z 2026-07-15 17:40:54 -05:00
Jarvis c806d75200 comms: homelab 20260715T223624Z 2026-07-15 17:36:24 -05:00
wjarvis mos-comms 566ba5a913 comms: usc 20260715T223527Z 2026-07-15 17:35:28 -05:00
Jarvis 21d2e94baf comms: homelab 20260715T223027Z 2026-07-15 17:30:27 -05:00
wjarvis mos-comms dd1730c7f2 comms: usc 20260715T222913Z 2026-07-15 17:29:13 -05:00
Jarvis 7ac93508d5 comms: homelab 20260715T222747Z 2026-07-15 17:27:47 -05:00
Jarvis 1e140979df comms: homelab 20260715T213632Z 2026-07-15 16:36:33 -05:00
wjarvis mos-comms 74b8b19e87 comms: usc 20260715T210002Z 2026-07-15 16:00:02 -05:00
Jarvis 8c1fc60fa5 comms: homelab 20260715T205856Z 2026-07-15 15:58:56 -05:00
Jarvis b29cd82237 comms: homelab 20260715T205616Z 2026-07-15 15:56:16 -05:00
Jarvis 5dce007464 comms: homelab 20260715T205055Z 2026-07-15 15:50:55 -05:00
wjarvis mos-comms ecd991bfb0 comms: usc 20260715T204215Z 2026-07-15 15:42:15 -05:00
Jarvis f54cda33bc comms: homelab 20260715T203959Z 2026-07-15 15:39:59 -05:00
wjarvis mos-comms e733dcf3f1 comms: usc 20260715T203832Z 2026-07-15 15:38:32 -05:00
Jarvis 1b0d605a7e comms: homelab 20260715T203725Z 2026-07-15 15:37:25 -05:00
wjarvis mos-comms a9202518a4 comms: usc 20260715T202510Z 2026-07-15 15:25:10 -05:00
Jarvis 4306421eae comms: homelab 20260715T202253Z 2026-07-15 15:22:53 -05:00
wjarvis mos-comms 86fac8f22a comms: usc 20260715T201615Z 2026-07-15 15:16:16 -05:00
Jarvis 755b4ca9ac comms: homelab 20260715T201506Z 2026-07-15 15:15:06 -05:00
wjarvis mos-comms eecb01a978 comms: usc 20260715T200842Z 2026-07-15 15:08:42 -05:00
wjarvis mos-comms e456baea66 comms: usc 20260715T200231Z 2026-07-15 15:02:31 -05:00
Jarvis cce17bb689 comms: homelab 20260715T195810Z 2026-07-15 14:58:10 -05:00
Jarvis 72d087bee1 comms: homelab 20260715T195004Z 2026-07-15 14:50:04 -05:00
wjarvis mos-comms 3856a67749 comms: usc 20260715T194752Z 2026-07-15 14:47:52 -05:00
Jarvis c3bc77a233 comms: homelab 20260715T194625Z 2026-07-15 14:46:25 -05:00
wjarvis mos-comms b8e0f364b3 comms: usc 20260715T194426Z 2026-07-15 14:44:26 -05:00
Jarvis 081803e9e5 comms: homelab 20260715T194153Z 2026-07-15 14:41:53 -05:00
Jarvis 52994c3ec9 comms: homelab 20260715T191727Z 2026-07-15 14:17:27 -05:00
Jarvis fe07a0b5c1 comms: homelab 20260715T190349Z 2026-07-15 14:03:49 -05:00
Jarvis 5eff418a84 comms: homelab 20260715T185731Z 2026-07-15 13:57:31 -05:00
Jarvis 2519828566 comms: homelab 20260715T184308Z 2026-07-15 13:43:09 -05:00
Jarvis 4348e44b2e comms: homelab 20260715T180046Z 2026-07-15 13:00:46 -05:00
Jarvis d6246909ae comms: homelab 20260715T174141Z 2026-07-15 12:41:41 -05:00
Jarvis ab02ead32e comms: homelab 20260715T172156Z 2026-07-15 12:21:57 -05:00
wjarvis mos-comms d7e5b409c1 comms: usc 20260715T170232Z 2026-07-15 12:02:32 -05:00
JarvisandClaude Opus 4.8 204b09c672 comms: relay M3 completion and M4 start
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 12:01:44 -05:00
JarvisandClaude Opus 4.8 92a03906e5 comms: relay reviewed M3-002 head
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 11:40:20 -05:00
Jarvis 5f185e41ea comms: homelab 20260715T161510Z 2026-07-15 11:15:32 -05:00
Jarvis 666f6c9024 comms: homelab 20260715T160422Z 2026-07-15 11:04:48 -05:00
Jarvis ea98598c81 comms: homelab 20260715T155411Z 2026-07-15 10:54:29 -05:00
wjarvis mos-comms 634f4369af comms: usc 20260715T151932Z 2026-07-15 10:19:33 -05:00
Jarvis 5a06d65e2e comms: homelab 20260715T151844Z 2026-07-15 10:18:44 -05:00
Jarvis 4c1035a521 comms: homelab 20260715T150439Z 2026-07-15 10:04:39 -05:00
Jarvis 054b442296 comms: homelab 20260715T143907Z 2026-07-15 09:39:07 -05:00
Jarvis 653684c26d comms: homelab 20260715T143519Z 2026-07-15 09:35:19 -05:00
Jarvis 33fa790e3c comms: homelab 20260715T142701Z 2026-07-15 09:27:01 -05:00
Jarvis 2bc5cf6ab8 comms: homelab 20260715T142058Z 2026-07-15 09:20:58 -05:00
Jarvis 891461ea0d comms: homelab 20260715T141358Z 2026-07-15 09:13:58 -05:00
Jarvis ec334a6b3b comms: homelab 20260715T135717Z 2026-07-15 08:57:17 -05:00
Jarvis c87b894743 comms: homelab 20260715T135437Z 2026-07-15 08:54:37 -05:00
Jarvis 969581590f comms: homelab 20260715T134627Z 2026-07-15 08:46:27 -05:00
wjarvis mos-comms 26da717842 comms: usc 20260715T134511Z 2026-07-15 08:45:12 -05:00
Jarvis 7142d165c6 comms: homelab 20260715T134139Z 2026-07-15 08:41:39 -05:00
Jarvis d212f357b8 comms: homelab 20260715T133926Z 2026-07-15 08:39:26 -05:00
Jarvis 21de4b5eb5 comms: homelab 20260715T132843Z 2026-07-15 08:28:43 -05:00
Jarvis 252b40d1a3 comms: homelab 20260715T132547Z 2026-07-15 08:25:47 -05:00
Jarvis 5e291ad9ba comms: homelab 20260715T131939Z 2026-07-15 08:19:39 -05:00
Jarvis 9ca3271e9e comms: homelab 20260715T131705Z 2026-07-15 08:17:05 -05:00
Jarvis 33321f31e6 comms: homelab 20260715T130941Z 2026-07-15 08:09:42 -05:00
Jarvis 4c070533c0 comms: homelab 20260715T125423Z 2026-07-15 07:54:23 -05:00
Jarvis bac5b67a52 comms: homelab 20260715T123922Z 2026-07-15 07:39:22 -05:00
Jarvis 7dd4f6409a comms: homelab 20260715T121539Z 2026-07-15 07:15:40 -05:00
wjarvis mos-comms 15feb6cd1e comms: usc 20260715T121334Z 2026-07-15 07:13:35 -05:00
Jarvis aefa13c755 comms: homelab 20260715T121311Z 2026-07-15 07:13:11 -05:00
Jarvis eb6a792499 comms: homelab 20260715T120339Z 2026-07-15 07:03:39 -05:00
Jarvis bc12bd1b6e comms: homelab 20260715T115602Z 2026-07-15 06:56:02 -05:00
Jarvis cd4d67e96e comms: homelab 20260715T115125Z 2026-07-15 06:51:25 -05:00
Jarvis 9690f95a3e comms: homelab 20260715T113159Z 2026-07-15 06:31:59 -05:00
Jarvis f814ab1322 comms: homelab 20260715T112519Z 2026-07-15 06:25:19 -05:00
Jarvis 422cbdf237 comms: homelab 20260715T111410Z 2026-07-15 06:14:10 -05:00
Jarvis a062fab0fe comms: homelab 20260715T110805Z 2026-07-15 06:08:05 -05:00
Jarvis c679fa8a75 comms: homelab 20260715T105236Z 2026-07-15 05:52:36 -05:00
Jarvis d441e4e204 comms: homelab 20260715T102331Z 2026-07-15 05:23:32 -05:00
Jarvis a08a8dd22f comms: homelab 20260715T101607Z 2026-07-15 05:16:07 -05:00
Jarvis 155cb142bc comms: homelab 20260715T100803Z 2026-07-15 05:08:03 -05:00
Jarvis 0cb3e75833 comms: homelab 20260715T092347Z 2026-07-15 04:23:47 -05:00
Jarvis 8281ab0c3e comms: homelab 20260715T092251Z 2026-07-15 04:22:51 -05:00
wjarvis mos-comms 89759b5f46 comms: usc 20260715T085727Z 2026-07-15 03:57:27 -05:00
Jarvis 8449dcc2cc comms: homelab 20260715T085637Z 2026-07-15 03:56:37 -05:00
Jarvis e019db3371 comms: homelab 20260715T084149Z 2026-07-15 03:41:49 -05:00
Jarvis bb9447f004 comms: homelab 20260715T083606Z 2026-07-15 03:36:06 -05:00
Jarvis 5fdc88a7c7 comms: homelab 20260715T082510Z 2026-07-15 03:25:10 -05:00
Jarvis 68f2f80669 comms: homelab 20260715T081457Z 2026-07-15 03:14:57 -05:00
Jarvis 4bb2c749c3 comms: homelab 20260715T075153Z 2026-07-15 02:51:53 -05:00
Jarvis 92170dcc4c comms: homelab 20260715T073238Z 2026-07-15 02:32:38 -05:00
Jarvis 90aed6220f comms: homelab 20260715T065251Z 2026-07-15 01:52:51 -05:00
Jarvis 1a91a13edb comms: homelab 20260715T062559Z 2026-07-15 01:25:59 -05:00
Jarvis 9fd779bc66 comms: homelab 20260715T060856Z 2026-07-15 01:08:56 -05:00
Jarvis 5460e5c63f comms: homelab 20260715T055934Z 2026-07-15 00:59:35 -05:00
Jarvis 4ee9b473e9 comms: homelab 20260715T054844Z 2026-07-15 00:48:44 -05:00
Jarvis bb2704527e comms: homelab 20260715T054515Z 2026-07-15 00:45:16 -05:00
Jarvis 35cdaf0d81 comms: homelab 20260715T053708Z 2026-07-15 00:37:08 -05:00
Jarvis 3c47980919 comms: homelab 20260715T053137Z 2026-07-15 00:31:37 -05:00
Jarvis 0087a7d6dc comms: homelab 20260715T052710Z 2026-07-15 00:27:10 -05:00
Jarvis 75b6a1a719 comms: homelab 20260715T044456Z 2026-07-14 23:44:56 -05:00
wjarvis mos-comms cb4853a5e4 comms: usc 20260715T044358Z 2026-07-14 23:43:58 -05:00
Jarvis 1ffc4b982c comms: homelab 20260715T043902Z 2026-07-14 23:39:02 -05:00
Jarvis 2bb1304e5d comms: homelab 20260715T040852Z 2026-07-14 23:08:52 -05:00
Jarvis e56b7c2114 comms: homelab 20260715T022820Z 2026-07-14 21:28:21 -05:00
Jarvis 6c338b8c34 comms: homelab 20260715T014941Z 2026-07-14 20:49:41 -05:00
wjarvis mos-comms d80bbaf335 comms: usc 20260715T014709Z 2026-07-14 20:47:10 -05:00
Jarvis 52fa2ae0bb comms: homelab 20260715T014556Z 2026-07-14 20:45:56 -05:00
Jarvis 9752175cf4 comms: homelab 20260715T012719Z 2026-07-14 20:27:19 -05:00
Jarvis 19abfc0efd comms: homelab 20260715T010915Z 2026-07-14 20:09:15 -05:00
Jarvis b938f54ae1 comms: homelab 20260715T010355Z 2026-07-14 20:03:55 -05:00
Jarvis 9ba1cb992f comms: homelab 20260715T003554Z 2026-07-14 19:35:54 -05:00
Jarvis 5253689bfc comms: homelab 20260715T001819Z 2026-07-14 19:18:19 -05:00
wjarvis mos-comms fd424de30e comms: usc 20260715T001533Z 2026-07-14 19:15:34 -05:00
Jarvis a482545412 comms: homelab 20260715T001300Z 2026-07-14 19:13:00 -05:00
Jarvis b0e9c92f6c docs: publish reviewed North Star rev4 artifact 2026-07-14 19:12:47 -05:00
Jarvis 26c464d68a comms: homelab 20260715T000906Z 2026-07-14 19:09:06 -05:00
Jarvis 283d44a707 comms: homelab 20260715T000144Z 2026-07-14 19:01:45 -05:00
wjarvis mos-comms 840da12ad5 comms: usc 20260714T235749Z 2026-07-14 18:57:49 -05:00
Jarvis 944598dc52 comms: homelab 20260714T235020Z 2026-07-14 18:50:20 -05:00
Jarvis 2b4c3643c3 comms: homelab 20260714T234258Z 2026-07-14 18:42:58 -05:00
Jarvis 5b925faa6d comms: homelab 20260714T233100Z 2026-07-14 18:31:00 -05:00
JarvisandClaude Opus 4.8 ea0541f435 comms: publish North Star rev3 artifact
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-14 18:30:44 -05:00
Jarvis 77a3beeb50 comms: homelab 20260714T233025Z 2026-07-14 18:30:25 -05:00
Jarvis 2953a58f55 comms: homelab 20260714T231003Z 2026-07-14 18:10:03 -05:00
wjarvis mos-comms baf0545cd2 comms: usc 20260714T230329Z 2026-07-14 18:03:30 -05:00
Jarvis 72a3bae6b0 comms: homelab 20260714T230224Z 2026-07-14 18:02:24 -05:00
wjarvis mos-comms 497dd6c0a6 comms: usc 20260714T225953Z 2026-07-14 17:59:54 -05:00
Jarvis 834f0126eb comms: homelab 20260714T225207Z 2026-07-14 17:52:07 -05:00
wjarvis mos-comms e8780f20ab comms: usc 20260714T224821Z 2026-07-14 17:48:21 -05:00
wjarvis mos-comms 3c1b3eb9bd comms: usc 20260714T223945Z 2026-07-14 17:39:45 -05:00
Jarvis 6233ba297d comms: homelab 20260714T222313Z 2026-07-14 17:23:13 -05:00
Jarvis b4be5b4d3f comms: homelab 20260714T221926Z 2026-07-14 17:19:26 -05:00
Jarvis 174a28af75 comms: publish web control-plane rev2 planning artifact 2026-07-14 17:19:16 -05:00
wjarvis mos-comms 25e5269ff3 comms: usc 20260714T221820Z 2026-07-14 17:18:20 -05:00
Jarvis 245fc723c2 comms: homelab 20260714T221751Z 2026-07-14 17:17:51 -05:00
wjarvis mos-comms 8c3b402ef0 comms: usc 20260714T220425Z 2026-07-14 17:04:25 -05:00
Jarvis 3c8d295ecf comms: homelab 20260714T220151Z 2026-07-14 17:01:51 -05:00
Jarvis 44b5c3aa52 comms: homelab 20260714T215708Z 2026-07-14 16:57:08 -05:00
wjarvis mos-comms c9f6e97a80 comms: usc 20260714T215454Z 2026-07-14 16:54:55 -05:00
Jarvis f37e069a94 comms: homelab 20260714T214833Z 2026-07-14 16:48:33 -05:00
Jarvis 19ffe3d93d comms: homelab 20260714T214458Z 2026-07-14 16:44:58 -05:00
Jarvis d22f08d324 comms: homelab 20260714T212528Z 2026-07-14 16:25:28 -05:00
Jarvis 303b9a6025 comms: homelab 20260714T211055Z 2026-07-14 16:10:56 -05:00
wjarvis mos-comms d8f2ac23ac comms: usc 20260714T205720Z 2026-07-14 15:57:20 -05:00
Jarvis 5742d76877 comms: homelab 20260714T205546Z 2026-07-14 15:55:46 -05:00
Jarvis 507139e34c comms: transmit frozen control-plane planning artifact 2026-07-14 15:55:37 -05:00
wjarvis mos-comms 3683aba627 comms: usc 20260714T205417Z 2026-07-14 15:54:17 -05:00
Jarvis aa831a785a comms: homelab 20260714T205313Z 2026-07-14 15:53:14 -05:00
wjarvis mos-comms 558696e539 comms: usc 20260714T205016Z 2026-07-14 15:50:17 -05:00
Jarvis d181ba6c22 comms: homelab 20260714T204515Z 2026-07-14 15:45:15 -05:00
Jarvis 7bbe34ee16 comms: homelab 20260714T203927Z 2026-07-14 15:39:27 -05:00
Jarvis c6e1afdda4 comms: homelab 20260714T203140Z 2026-07-14 15:31:41 -05:00
Jarvis c32314236a comms: homelab 20260714T203037Z 2026-07-14 15:30:37 -05:00
wjarvis mos-comms 2c3eb60441 comms: usc 20260714T202535Z 2026-07-14 15:25:35 -05:00
Jarvis c0ec6bfcbf comms: homelab 20260714T202403Z 2026-07-14 15:24:04 -05:00
Jarvis 05a2482aeb comms: homelab 20260714T202111Z 2026-07-14 15:21:11 -05:00
wjarvis mos-comms c2b866b37c comms: usc 20260714T202014Z 2026-07-14 15:20:14 -05:00
Jarvis cd13c6aa15 comms: homelab 20260714T201717Z 2026-07-14 15:17:18 -05:00
wjarvis mos-comms 067e2eced1 comms: usc 20260714T201537Z 2026-07-14 15:15:37 -05:00
Jarvis 4b7edd7a81 comms: homelab 20260714T201339Z 2026-07-14 15:13:39 -05:00
wjarvis mos-comms b5daf988a2 comms: usc 20260714T201245Z 2026-07-14 15:12:46 -05:00
Jarvis f50ed8f86f comms: homelab 20260714T201107Z 2026-07-14 15:11:07 -05:00
Jarvis 8b68c11da2 comms: homelab 20260714T200349Z 2026-07-14 15:03:50 -05:00
wjarvis mos-comms cdaa32d648 comms: usc 20260714T200246Z 2026-07-14 15:02:46 -05:00
wjarvis mos-comms dd49c3ba57 comms: usc 20260714T200237Z 2026-07-14 15:02:37 -05:00
Jarvis e4e98e97d9 comms: homelab 20260714T200114Z 2026-07-14 15:01:14 -05:00
Jarvis 3a7dd7d430 comms: homelab 20260714T195722Z 2026-07-14 14:57:22 -05:00
wjarvis mos-comms 062a2f1c97 comms: usc 20260714T195643Z 2026-07-14 14:56:44 -05:00
Jarvis a961bdda47 comms: homelab 20260714T195418Z 2026-07-14 14:54:18 -05:00
wjarvis mos-comms 1dfc68531f comms: usc 20260714T194043Z 2026-07-14 14:40:43 -05:00
wjarvis mos-comms 1658a29abd comms: usc 20260714T193952Z 2026-07-14 14:39:52 -05:00
Jarvis f29d7bc86d comms: homelab 20260714T193858Z 2026-07-14 14:38:58 -05:00
wjarvis mos-comms eecc6e3606 comms: usc 20260714T193803Z 2026-07-14 14:38:03 -05:00
wjarvis mos-comms 71042ad674 comms: usc 20260713T215109Z 2026-07-13 16:51:09 -05:00
Jarvis c20437b289 comms: homelab 20260713T215012Z 2026-07-13 16:50:12 -05:00
Jarvis dcd8e41d5d comms: homelab 20260713T214937Z 2026-07-13 16:49:37 -05:00
wjarvis mos-comms e047d33767 comms: usc 20260713T214921Z 2026-07-13 16:49:21 -05:00
wjarvis mos-comms 88a7822dfd comms: usc 20260713T214747Z 2026-07-13 16:47:47 -05:00
Jarvis 2dcc490dbc comms: homelab 20260713T214552Z 2026-07-13 16:45:52 -05:00
wjarvis mos-comms 52b4ccf399 comms: usc 20260713T214234Z 2026-07-13 16:42:34 -05:00
Jarvis 1156555f9d comms: homelab 20260713T214216Z 2026-07-13 16:42:17 -05:00
wjarvis mos-comms 0602dcdef3 comms: usc 20260713T214035Z 2026-07-13 16:40:37 -05:00
Jarvis 37e72bb29a comms: homelab 20260713T213922Z 2026-07-13 16:39:22 -05:00
homelab mos-comms 32443452fa brief: Decision #3 RATIFIED (Option A) + owner flexibility amendment (configurable recovery posture) 2026-07-13 16:39:21 -05:00
Jarvis f4eb75dc6b comms: homelab 20260713T213206Z 2026-07-13 16:32:06 -05:00
homelab mos-comms e8806df728 brief: Kanban SOT Decision #3 — independent verdict (Option A) 2026-07-13 16:32:06 -05:00
Jarvis f1a149a7f5 comms: homelab 20260713T212934Z 2026-07-13 16:29:34 -05:00
wjarvis mos-comms 7f700d4ca1 comms: usc 20260713T212611Z 2026-07-13 16:26:11 -05:00
wjarvis mos-comms fe71a42b28 comms: usc 20260713T212142Z 2026-07-13 16:21:42 -05:00
Jarvis fb373d5c88 comms: homelab 20260713T211947Z 2026-07-13 16:19:47 -05:00
Jarvis ae4ce99f4d comms: wjarvis 20260713T211701Z 2026-07-13 16:17:01 -05:00
wjarvis mos-comms a70fccccfa comms: wjarvis 20260713T211213Z 2026-07-13 16:12:13 -05:00
wjarvis mos-comms 9c820e2eb8 comms: wjarvis 20260713T211121Z 2026-07-13 16:11:21 -05:00
jason.woltje d077183554 docs(tess): remediate M5 qualification findings (#750)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 19:59:38 +00:00
jason.woltje 405984af5a De-hardcode orchestrator and interaction agent names (#748)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 18:59:27 +00:00
jason.woltje 8dd4e9d541 docs(tess): ledger sync m4 — M5-003 done, #745/#746 merged (#749)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 18:14:24 +00:00
jason.woltje bc8016c831 docs(#744): complete Tess documentation gate (#746)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 17:44:17 +00:00
jason.woltje e72388b2cb docs(tess): ledger sync m3 — M5-001 + M5-002 done (#745)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 16:14:23 +00:00
jason.woltje 6345dbfcf2 feat(agent): add Matrix native runtime provider (#744)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 15:29:37 +00:00
jason.woltje c6e3cfbd95 docs(tess): ledger sync — W-001 3-of-3 merged, M5-002 approved, M5-001 in TDD (#743)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-13 15:29:33 +00:00
jason.woltje 5789711ee0 docs(tess): add migration evidence set (#742)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 15:14:29 +00:00
jason.woltje f40e6ba388 docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending) (#741)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 15:00:15 +00:00
jason.woltje b7b0f508e6 feat(gateway): register Hermes runtime provider (#740)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 14:45:08 +00:00
jason.woltje 3378b857eb feat(memory): bind operator plugin to agent sessions (#739)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 13:44:20 +00:00
jason.woltje e2376190e5 feat(gateway): expose Mos coordination boundary (#737)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-13 13:14:44 +00:00
jason.woltje cca6aaf947 feat(agent): add Hermes transitional capability matrix (#738)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-13 13:14:43 +00:00
jason.woltje 2363f155b4 feat(memory): add operator retrieval plugin (#736)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 12:44:31 +00:00
jason.woltje 76325ca3f2 feat(tess): add Mos coordination boundary (#735)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 11:59:16 +00:00
jason.woltje 9e5b9188ce feat(agent): add transitional Hermes runtime adapter (#734)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 11:29:27 +00:00
jason.woltje f1c6b37b46 fix(tess): route bare Discord approvals (#733)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 10:29:10 +00:00
jason.woltje 0b621660c8 feat(tess): wire durable interaction surfaces (#732)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 10:05:29 +00:00
jason.woltje 84d884b932 feat(#709): add configured Discord interaction binding (#730)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 09:02:45 +00:00
jason.woltje 8246ee0137 feat(tess): add generic interaction CLI (#731)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 05:52:41 +00:00
jason.woltje 99a2d0fc9d feat(tess): persist durable session state (#729)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 05:14:11 +00:00
jason.woltje e3b5113be2 feat(tess): add configurable Pi interaction service (#728)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 02:59:27 +00:00
jason.woltje 24b07d0f83 docs(tess): sync M1 ledger to merged state; M1-V Mos-owned (#727)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 02:14:13 +00:00
jason.woltje 86a50138a9 feat(tess): add safe runtime observability (#726)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 01:29:18 +00:00
jason.woltje 7b9f40d3b7 fix(tess): redact chat persistence and egress (#725)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 01:14:16 +00:00
jason.woltje 9a8a572fcf feat(tess): add roster-bound tmux fleet provider (#724)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 00:59:24 +00:00
jason.woltje 753a360517 fix(#707): scope session GC retention (#720)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 00:44:19 +00:00
jason.woltje e92186d768 feat: add Tess runtime provider registry (#722)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 23:53:54 +00:00
jason.woltje 119f64e69d feat(#707): secure Discord service ingress (#716)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 23:18:29 +00:00
jason.woltje 46ca3ce742 fix(tess): enforce command authorization approvals (#718)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-12 23:18:01 +00:00
jason.woltje 227b73fcdf fix(security): enforce Tess MCP server identity (#717)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 22:49:00 +00:00
jason.woltje a959b1d6b4 ci: restrict push-event CI to protected branches (halve feature-branch load) (#721)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:48:58 +00:00
jason.woltje 62f8177806 feat(tess): define runtime provider contract (#719)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-12 22:29:21 +00:00
jason.woltje 353e43c947 fix: enforce Tess session ownership scope (#715)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:14:17 +00:00
jason.woltje ca9c2b5c23 restore Tess markdown formatting gate (v2, non-author) (#714)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-07-12 21:32:16 +00:00
jason.woltje b580d37d51 Fixes #703 (#705)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-12 20:49:43 +00:00
jason.woltje 59e49cfd15 docs(tess): define Pi-native interaction agent mission (#712)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-12 18:09:54 +00:00
jason.woltje a99aded26d fix(tools/git): -h/--help now exits 0 across 7 wrappers (#702)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-11 09:23:46 +00:00
jason.woltje 4df38f7e81 fix(tools/_lib): /etc/mosaic host-level fallback for credential resolution (#700)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-10 01:57:12 +00:00
jason.woltje 4e9e053800 fix(tools/tmux): unique per-invocation paste buffer; track auto-submit-drafts.sh (#697)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-09 17:37:40 +00:00
jason.woltje 851c67c27b docs(bootstrap): add python-is-python3 to agent-host prerequisites (#694)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-03 09:38:40 +00:00
jason.woltje 86e106fcc9 feat(#462): add federation list verb (#682)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-25 02:15:17 +00:00
jason.woltje 67135d3822 fix(fleet): guard mosaic fleet restart against tight-loop re-entry race (#680)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-25 01:44:48 +00:00
jason.woltje adb153428b feat(installer): --dev flag builds CLI + gateway from source (#681)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 23:54:52 +00:00
jason.woltje c739256a2c feat(#462): add federation scope enforcement service (#672)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 23:22:46 +00:00
jason.woltje fc2970916f style(federation): re-run prettier on TASKS.md (unblock format:check) (#679)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 22:51:22 +00:00
jason.woltje 79eae2ffce fix(fleet): raise fleet-personas.spec timeout to 30s (mirror #665) (#677)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-24 22:16:49 +00:00
jason.woltje 7035cd23bf docs(federation): record M3-07/09 merges + fix M3-11 dependency DAG (#678)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 21:44:57 +00:00
jason.woltje 6b94d014a8 feat(#462): add federation capabilities verb (#674)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-24 21:39:56 +00:00
jason.woltje 838c44086c feat(#462): add federation query source routing (#673)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-24 21:39:45 +00:00
jason.woltje 3eeed04e17 docs(federation): sync M3 backlog to origin/main reality (#671)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 21:15:14 +00:00
jason.woltje e0e7be70f5 chore(release): @mosaicstack/mosaic 0.0.48 (#670)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 20:01:17 +00:00
jason.woltje d7eaa19380 feat(fleet): provision roster from system-type profile (H3) (#665)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 19:48:54 +00:00
jason.woltje 248193cd3b fix(fleet): export MOSAIC_AGENT_CLASS into the agent pane so personas inject (#669)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 19:38:15 +00:00
jason.woltje a9857c5043 fix(release): republish @mosaicstack/db 0.0.4 with BacklogService; mosaic 0.0.47 (#668)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 18:54:33 +00:00
jason.woltje e4ede69144 chore(release): mosaic 0.0.46 (persona contracts live) (#666)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 18:29:01 +00:00
jason.woltje a8008138c8 docs(fleet): record per-agent model switch in north star (#667)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 18:14:37 +00:00
jason.woltje 0d17a29ebe feat(fleet): export MOSAIC_AGENT_CLASS into agent env (A3a) (#663)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 17:19:59 +00:00
jason.woltje 28cfecda94 feat(fleet): inject persona contract at launch (A3b) (#664)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-06-24 17:06:51 +00:00
jason.woltje 6c84ccd0b1 feat(fleet): dedicated orchestrator persona, split from planner (#662)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 16:42:23 +00:00
jason.woltje 84d2757817 feat(fleet): update-surviving persona customization (H4) (#661)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 16:21:01 +00:00
jason.woltje a738ac1410 feat(fleet): system-type profiles (H2) (#660)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 16:02:25 +00:00
jason.woltje 538f0556d5 feat(fleet): cross-domain baseline persona library (H1) (#659)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 15:31:56 +00:00
jason.woltje a094c86eea feat(fleet): North Star scope — general-purpose system, personas & system profiles (workstream H) (#658)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-24 15:25:57 +00:00
jason.woltje f852250419 feat(fleet): native Mosaic backlog on @mosaicstack/db (atomic claim + TTL) (#657)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-24 14:55:10 +00:00
jason.woltje 61b1bdac2a feat(fleet): add machine-readable NORTH_STAR.yaml + Markdown projection (#656)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 14:40:09 +00:00
jason.woltje cabb179d5a feat(fleet): seed role registry markdown library (#655)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-24 14:39:54 +00:00
jason.woltje eb795bab18 chore(release): mosaic CLI 0.0.45 (#654)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 14:11:33 +00:00
jason.woltje 937077f6be fix(fleet): report idle agents as available, reserve stuck for genuine blocks (#653)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 13:58:22 +00:00
jason.woltje 1020cfaf9b chore(release): mosaic CLI 0.0.44 (#652)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 06:49:04 +00:00
jason.woltje 70661e3fab fix(fleet): derive pane idle from window activity fallback (#651)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 06:37:45 +00:00
jason.woltje ec8dd7ca86 chore(release): mosaic CLI 0.0.43 (#650)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 06:08:20 +00:00
jason.woltje d887555852 feat(fleet): classify agent readiness in fleet ps (#649)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 05:55:47 +00:00
jason.woltje e3adc6a1bc chore(release): mosaic CLI 0.0.42 (#648)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 05:28:28 +00:00
jason.woltje aa27c42129 fix(fleet): pre-trust claude agent workdir to clear the folder-trust gate (#644) (#645)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 05:16:46 +00:00
jason.woltje 16ae809442 fix(update): re-seed framework on version drift, not just in-command updates (#642) (#646)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 05:04:34 +00:00
jason.woltje 6980e40e51 fix(db): stop pglite migration tests flaking CI (timeout + WASM OOM) (#647)
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
2026-06-24 05:04:28 +00:00
jason.woltje e6b53ea103 fix(tools): default AGENT_WORK_ROOT to $HOME/mosaic/agent-work (#641)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
2026-06-23 13:40:13 +00:00
jason.woltje 4da87640e8 feat(tmux): agent-send.sh --class triage tag for the comms daemon (#552)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-23 03:25:16 +00:00
jason.woltje a38a491403 chore(release): mosaic CLI 0.0.41 (#640)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-23 02:21:04 +00:00
jason.woltje 78d67c6261 chore(ci): bump ci-base image node 22 → 24-alpine (#639)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-23 00:59:39 +00:00
jason.woltje 94e5cd7a81 ci: eliminate cold pnpm install via pre-baked CI base image (Phase 1) (#635)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-22 22:50:21 +00:00
jason.woltje 4e84f8e850 feat(fleet): comms-block emitter + FLEET-LAUNCH runbook (#633) (#638)
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 22:23:50 +00:00
jason.woltje cf8ceb3095 CI: add pre-baked ci-base image (producer) [Phase 1a] (#637)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-22 22:20:48 +00:00
jason.woltje bf2a6745c8 fix(install): preserve user fleet data on re-seed + refresh active units (CRITICAL) (#632)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 21:38:09 +00:00
jason.woltje d539d61e0e refactor(fleet): rename tmux socket mosaic-factory → mosaic-fleet (#630)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 21:08:43 +00:00
jason.woltje 3f69d45334 docs(fleet): consolidate north-star doctrine (budget + control plane + identity) (#629)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 21:08:41 +00:00
jason.woltje e2336bb0ca chore(release): mosaic CLI 0.0.40 (#624)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-22 19:49:45 +00:00
jason.woltje 7342415a32 fix(fleet): consume model_hint + fix socket-default trap (stand-up fixes) (#627)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 19:18:01 +00:00
jason.woltje 095e19443b feat(fleet): onboarding-injection — comms cheat-sheet + peer roster per agent (#621)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 17:54:54 +00:00
jason.woltje fabc413407 feat(fleet): F4 Phase 2a — Matrix CS-API connector client + factory (#618)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 16:48:17 +00:00
jason.woltje 858d90329d feat(fleet): F4 Phase 1 — chat connector abstraction + Matrix design (#617)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 16:14:32 +00:00
jason.woltje 2bf66136e4 feat(fleet): enhancer role + two-agent floor (orchestrator + enhancer) (#615)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 13:15:59 +00:00
jason.woltje 4434c3c481 docs(fleet): orchestrator+enhancer two-agent floor + role library + Discord plugin north-star (#613)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-22 13:15:05 +00:00
jason.woltje dd0a0d38c6 ci(publish): gate kaniko image builds + publish on changed paths (CI throughput) (#619)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 13:14:31 +00:00
jason.woltje d46ac40890 fix(fleet): boot-survival symmetry — disable-on-remove + add-enable + init-R5 (#612)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 08:12:58 +00:00
jason.woltje 8ddd48c843 feat(mosaic): mosaic update re-seeds framework + relaunches agents (R13) (#610)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 03:34:05 +00:00
jason.woltje 528700ceea feat(framework): P6 — docs + compliance matrix + resident-budget CI (#607)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/tag/publish Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 02:20:35 +00:00
jason.woltje 32f4215461 chore(release): bump @mosaicstack/mosaic 0.0.38 -> 0.0.39 (#608)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-22 02:16:12 +00:00
jason.woltje 23343bb7f0 feat(mosaic): P5 — overlay composer (compose-contract + *.local overlays) (#605)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 02:16:05 +00:00
jason.woltje c8b2dab0ca chore(release): bump @mosaicstack/mosaic 0.0.37 -> 0.0.38 (#603)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-22 01:48:27 +00:00
jason.woltje 6dbe452a9f fix(fleet): watch viewer-session leak + workdir test settle-race (#601)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 01:43:21 +00:00
jason.woltje 59c755067e feat(fleet): F3-m2 — native Pi heartbeat + model surface + mosaic_mission_status tool (#602)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 01:43:18 +00:00
jason.woltje 6ffb27787e fix(fleet): complete HB reader/writer consistency + sidecar hardening (#599)
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-22 01:22:35 +00:00
jason.woltje 130837365f chore(release): bump @mosaicstack/mosaic 0.0.36 -> 0.0.37 (#597)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 23:27:14 +00:00
jason.woltje 67df06f1c4 feat(fleet): orchestrator-mutable fleet — fleet add/remove (F5/R9) (#596)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:26:21 +00:00
jason.woltje 60a309d5a4 fix(fleet): heartbeat consistency — MOSAIC_HOME path + configurable interval (#595)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 23:25:53 +00:00
jason.woltje 2dc0f24828 docs(fleet): Fleet Suite PRD (init/configure/operate + Mos-on-Discord) (#588)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:17:10 +00:00
jason.woltje 31e7a4d25e docs(framework): P4.1 — fix stale install.sh comments + cmp-equal early-exit (#593)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 23:12:31 +00:00
jason.woltje ca19d57bba feat(fleet): config-type presets + AI-free init wizard (F1) (#591)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:07:41 +00:00
jason.woltje bb7d549080 feat(framework): P4 — upgrade-safe Constitution migration (both installers) (#590)
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 23:03:48 +00:00
jason.woltje 5bef2c35eb feat(fleet): fleet ps surfaces unmanaged socket sessions (#586)
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
2026-06-21 22:37:34 +00:00
jason.woltje 2849a8f9db chore(release): bump @mosaicstack/mosaic 0.0.35 -> 0.0.36 (#585)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 21:46:15 +00:00
jason.woltje 7ced5588c9 feat(fleet): launcher heartbeat sidecar — HB for all runtimes (pi/claude/codex) (#584)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-21 21:14:20 +00:00
jason.woltje afcbbb302f feat(fleet): auto-enable units on install + drift recognizes wrapped runtimes (#583)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 20:02:19 +00:00
jason.woltje c2c0b5fe8d chore(release): bump @mosaicstack/mosaic 0.0.34 -> 0.0.35 (#582)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 18:59:39 +00:00
jason.woltje c9cfe36204 docs(framework): P3.1 fast-follow — governance wording + gate scope + bare-launch note (#577)
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 18:56:50 +00:00
jason.woltje fc90c89913 fix(fleet): durable runtime PATH for detached agent launch (#581)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 17:30:40 +00:00
jason.woltje af2eede7a9 feat(fleet): Phase-2 observability — fleet ps + watch + send verify (#579)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 04:23:51 +00:00
jason.woltje 5118be74cb feat(framework): P3 — extract Constitution (L0) + gut AGENTS dispatcher (#575)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 03:20:32 +00:00
jason.woltje bf24066a49 feat(framework): P1+P2 — public sanitization + blocking CI gate (#572)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 02:40:11 +00:00
jason.woltje 92316ab41e feat(framework): P0 — MIT license + executable-leak sanitization (#570)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 01:43:49 +00:00
jason.woltje b354bc8fae docs(framework): add agency & persistence patterns to config + guides (#543)
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <[email protected]>
Co-committed-by: Jason Woltje <[email protected]>
2026-06-21 01:43:36 +00:00
jason.woltje e834bbb83c fix(fleet): install executable tmux helpers (#568)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 22:27:46 +00:00
jason.woltje 7498fcb20d fix(fleet): preserve agent env overrides on install (#567)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 21:50:46 +00:00
jason.woltje 42d081613f chore(release): bump mosaic cli to 0.0.32 (#566)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 21:15:25 +00:00
jason.woltje b5c1381e45 fix(fleet): harden operator sends for release (#565)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 20:41:11 +00:00
jason.woltje 6dfd78f643 feat(fleet): add local canary CLI (#563)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 17:49:01 +00:00
jason.woltje 45e2c2aad8 docs: plan durable tmux fleet install (#557)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 16:19:19 +00:00
jason.woltje 57919c38d8 fix(framework/tools): wrapper hardening — TLS validation, cred-path fallback, no-CI fast-exit (#551)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 10:16:38 +00:00
jason.woltje 87f561c1f8 fix(launch): include Pi native skill roots in 'all' mode; dedup 'discover' force-loads (#556)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 19:58:09 +00:00
jason.woltje 8c45857859 feat(launch): force-load fleet-critical Pi skills + reconcile skill docs (#555)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 18:31:02 +00:00
jason.woltje 605221d42f docs(framework/tools): lead TOOLS.md with high-salience fleet-tools cheatsheet (#554)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-19 18:03:03 +00:00
jason.woltje ee584ab48c fix(framework/tools): prettier-format woodpecker README — restore main format gate (#553)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-18 22:39:35 +00:00
jason.woltje ab4e138003 feat(framework/tools): orchestration helpers — lane-brief.sh + ci-wait.sh (#547)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was canceled
2026-06-18 22:08:40 +00:00
jason.woltje 719c6ac3db fix(framework/tools): eval injection, broken JSON, tmpfile leak (#549)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-18 21:35:32 +00:00
jason.woltje b8807e60df feat(agent-reflection): durable kernel — reflection.v1 capture + risk-floor + Phase-0 (#545)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-16 21:35:40 +00:00
jason.woltje c461380a4a feat(mosaic-as): agent registration + scoped/revocable tokens (US-007) (#541)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-16 01:10:44 +00:00
jason.woltje 98a771c8f8 Fix Gitea wrapper login resolution (#538)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-12 02:34:18 +00:00
jason.woltje bd9527c033 docs(framework): canonize merge-authority policy (hard gate 13 + E2E gate note) (#537)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-11 23:56:20 +00:00
jason.woltje aa221bf92e release(mosaic): bump @mosaicstack/mosaic 0.0.30 -> 0.0.31 (#534)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-06-11 19:55:43 +00:00
jason.woltje 799df40f4e feat(appservice): room provisioning (M4c) (#535)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-11 19:50:55 +00:00
jason.woltje b79e9f32c6 chore(framework): canonize Vault-as-SSOT + ESO-default secrets policy (#519)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-11 19:07:00 +00:00
jason.woltje 89d69eb23b docs: add mission control and coordination resilience docs (#511)
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
2026-06-11 19:06:35 +00:00
jason.woltje 59b611ba8a refactor(framework): thin-core prompt diet — cut injected contract ~53% (#529)
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-06-11 18:10:42 +00:00
jason.woltje dfa0be42f6 feat(framework/tools): inter-agent tmux comms — agent-send.sh + addressing standard (#533)
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
2026-06-11 18:01:44 +00:00
jason.woltje bb96a3f23e ci: publish mosaic-as appservice image (#532)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-10 23:00:38 +00:00
jason.woltje 48b2f28e45 feat(appservice): mosaic-as daemon host + container (M4a) (#531)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-10 22:16:28 +00:00
jason.woltje 8f09c910a9 feat(appservice): Matrix Application Service core library (M4a) (#530)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-10 21:23:25 +00:00
jason.woltje dde95a59b3 fix(pi): reduce startup skill-token overhead (#527)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-05 18:36:42 +00:00
jason.woltje 821e19dcbb fix(mosaic-tools): roll up Gitea and Woodpecker wrapper fixes (#524)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-05-26 20:56:09 +00:00
jason.woltje 755df9079e Merge pull request 'fix(db): bootstrap migrations on local-tier gateway startup' (#510) from fix/db-bootstrap-migrations into main 2026-05-04 22:13:14 +00:00
jason.woltjeandClaude Opus 4.7 ac5650d9f9 fix(db): bootstrap migrations on local-tier gateway startup
Fresh `mosaic gateway install` (npm) left the gateway DB schema empty —
sign-in 500'd with `relation "users" does not exist`, and every entry
point (auth, bootstrap setup) failed because they all query the users
table first. Five stacked bugs on the local (PGlite) tier:

1. `packages/db/package.json` `files: ["dist"]` excluded the `drizzle/`
   SQL migrations from the published tarball.
2. `runMigrations()` only supports postgres-js — unusable for embedded
   PGlite.
3. `apps/gateway/src/database/database.module.ts` never invoked
   migrations at startup.
4. `createPgliteDb` didn't load pgvector, so migration 0001's
   `CREATE EXTENSION vector` failed.
5. Drizzle's PG migrator wraps every migration in one outer
   transaction, which trips Postgres' `check_safe_enum_use` on
   migration 0009 (`ALTER TYPE ADD VALUE 'pending'` → `SET DEFAULT
   'pending'` in the same tx).

Changes:
- Ship `drizzle/` in the published tarball.
- `createPgliteDb` loads `@electric-sql/pglite/vector`.
- New `runPgliteMigrations(handle)` walks the Drizzle journal and
  runs each statement-breakpoint chunk through PGlite's `client.exec()`
  (autocommit per statement). Records into `drizzle.__drizzle_migrations`
  for interop with the postgres-js path. Per-statement try/catch
  surfaces which statement of which migration failed.
- `DatabaseModule` runs migrations in `OnModuleInit` before
  `app.listen()`. Local tier: explicit `runPgliteMigrations` then
  `storageAdapter.migrate()`. Postgres tier: just `storageAdapter.migrate()`,
  which already calls `runMigrations(url)` internally — no double-call.
- Removed `packages/storage/src/test-utils/pglite-with-vector.ts`. The
  "intentionally not exported" rationale is moot now that migration
  0001 forces pgvector load anyway. The integration test uses
  `createPgliteDb` + `runPgliteMigrations` from `@mosaicstack/db`.

Tests: BetterAuth tables exist after migrate; idempotent (re-runs 0009);
partial-failure surfaces statement-level context and leaves no ledger row.

QA on a fresh PGlite install:
- `Applying PGlite schema migrations...` then `Initializing storage
  adapter (pglite)...` in startup log.
- `GET /api/bootstrap/status` → `{"needsSetup":true}` HTTP 200 (was 500).
- `POST /api/bootstrap/setup` reaches Zod validator (was 500).

Scope: this PR fixes the local (PGlite) tier. Postgres-tier first
install still has the outer-transaction problem and a journal ordering
bug (0009's `when` < 0008's). Documented inline as TODO and in the
scratchpad — needs a separate change with real-Postgres validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 17:06:50 -05:00
jason.woltje bd83f86740 Merge pull request 'feat(federation): mTLS AuthGuard with OID-based grant resolution (FED-M3-03)' (#509) from feat/federation-m3-auth-guard into main
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-25 13:27:20 +00:00
jarvisandClaude Sonnet 4.6 0af3e218a1 fix(federation/auth-guard): remediate CRIT-1/CRIT-2 + HIGH-1..4 review findings
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
- CRIT-1: Validate cert subjectUserId against grant.subjectUserId from DB;
  use authoritative DB value in FederationContext
- CRIT-2: Add @Inject(GrantsService) decorator (tsx/esbuild requirement)
- HIGH-1: Validate UTF8String TLV tag, length, and bounds in OID parser
- HIGH-2: Collapse all 403 wire messages to a generic string to prevent
  grant enumeration; keep internal logger detail
- HIGH-3: Assert federation wire envelope shape in all guard tests
- HIGH-4: Regression test for subjectUserId cert/DB mismatch

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-25 06:33:37 -05:00
jarvisandClaude Sonnet 4.6 b01c9b3bb0 feat(federation): mTLS AuthGuard with OID-based grant resolution (FED-M3-03)
Adds FederationAuthGuard that validates inbound mTLS client certs on
federation API routes. Extracts custom OIDs (grantId, subjectUserId),
loads the grant+peer from DB in one query, asserts active status, and
validates cert serial as defense-in-depth. Attaches FederationContext
to requests on success and uses federation wire-format error envelopes
(not raw NestJS exceptions) for 401/403 responses.

New files:
- apps/gateway/src/federation/oid.util.ts — shared OID extraction (no dupe ASN.1 logic)
- apps/gateway/src/federation/server/federation-auth.guard.ts — guard impl
- apps/gateway/src/federation/server/federation-context.ts — FederationContext type + module augment
- apps/gateway/src/federation/server/index.ts — barrel export
- apps/gateway/src/federation/server/__tests__/federation-auth.guard.spec.ts — 11 unit tests

Modified:
- apps/gateway/src/federation/grants.service.ts — adds getGrantWithPeer() with join
- apps/gateway/src/federation/federation.module.ts — registers FederationAuthGuard as provider

Closes #462

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-25 06:33:37 -05:00
jason.woltje b67f2c9f08 Merge pull request 'feat(federation): outbound mTLS FederationClient (FED-M3-08)' (#508) from feat/federation-m3-client into main
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-24 04:30:29 +00:00
jarvisandClaude Sonnet 4.6 37675ae3f2 fix(federation/client): serialize cache fills, destroy evicted Agent, cover env-var guard
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
- HIGH-A: resolveEntry now uses promise-cache pattern so concurrent
  callers serialize on a single in-flight build, eliminating duplicate
  key material in heap and duplicate DB round-trips
- HIGH-B: flushPeer destroys the evicted undici Agent so stale TLS
  connections close on cert rotation
- MED-C: add regression test for PEER_MISCONFIGURED when
  STEP_CA_ROOT_CERT_PATH is unset

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-23 22:56:57 -05:00
jarvisandClaude Opus 4.7 a4a6769a6d fix(federation/client): pin Step-CA root, fix lockfile, harden cache test
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
CRIT-1: regenerate pnpm-lock.yaml so apps/gateway resolves [email protected].6
(prior PR pushed package.json without lockfile update; CI failed with
ERR_PNPM_OUTDATED_LOCKFILE). Incidentally cleans 57 lines of stale
peer-dep entries.

CRIT-2: cache-hit test no longer swallows resolveEntry errors. Calls the
private method directly twice and asserts identity equality plus a
single DB select, removing the silent-failure path the prior assertion
allowed.

HIGH-1: mTLS Agent now pins Step-CA root via STEP_CA_ROOT_CERT_PATH.
Without the env var resolveEntry throws PEER_MISCONFIGURED, refusing to
dial peers against the public trust store. PEM is read once and cached
on the service instance.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-04-23 22:30:09 -05:00
jarvisandClaude Sonnet 4.6 21650fb194 feat(federation): outbound mTLS FederationClient (FED-M3-08)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
Implements FederationClientService — a NestJS injectable that dials peer
gateways over mTLS (undici Agent with cert+sealed-key from federation_peers),
invokes list/get/capabilities verbs, validates responses via Zod, and surfaces
all failure modes as typed FederationClientError with a coherent error code
taxonomy (PEER_NOT_FOUND, PEER_INACTIVE, PEER_MISCONFIGURED, NETWORK,
FORBIDDEN, HTTP_{status}, INVALID_RESPONSE).

Per-peer Agent instances are cached in a Map for the service lifetime;
flushPeer(peerId) invalidates the cache for M5/M6 cert rotation and
revocation events.

Wired into FederationModule providers + exports so QuerySourceService
(M3-09) can inject it.

13 unit tests covering all required scenarios via undici MockAgent +
real sealClientKey/unsealClientKey round-trip.

Closes #462

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-23 22:16:52 -05:00
jason.woltje 89c733e0b9 feat(federation): two-gateway test harness scaffold (FED-M3-02) (#505)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-24 03:01:25 +00:00
jason.woltje ee3f2defd9 feat(types): federation v1 DTOs (FED-M3-01) (#506)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-24 02:54:40 +00:00
jason.woltje 7342c1290d fix(federation): use real PEM certs in enrollment + ca service tests (#507)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-24 02:43:42 +00:00
jason.woltje e64ddd2c1c docs(federation): M3 mission planning — 14-task decomposition (#504)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-04-24 01:13:40 +00:00
1763 changed files with 99813 additions and 1738 deletions
+10
View File
@@ -12,3 +12,13 @@ docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example
infra/step-ca/dev-password
# Scratch dirs created by the framework git-wrapper shell test harnesses
.mosaic-test-work/
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
# loading it, then unlink. They are untracked but were not ignored, so turbo's
# package traversal hashed them and intermittently failed CI with "Package
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
# file vanished mid-scan. Ignoring them removes the race.
*.timestamp-*.mjs
+4
View File
@@ -1 +1,5 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
# instead of repopulating a fresh one.
store-dir=/root/.local/share/pnpm/store
+2
View File
@@ -5,3 +5,5 @@ pnpm-lock.yaml
**/drizzle
**/.next
.claude/
docs/tess/TASKS.md
docs/scratchpads/
+40
View File
@@ -0,0 +1,40 @@
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
# for their install step.
#
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
# code push must not trigger a 25-min image build. `path` applies to push/PR
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
# always ships a fresh base.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'pnpm-lock.yaml'
- 'Dockerfile.ci'
steps:
build-ci-base:
image: gcr.io/kaniko-project/executor:debug
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
# Lockfile-hash tag: an immutable identity for the exact dep set baked
# into this image. `:latest` is the mutable pointer pipelines consume.
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS
+46 -12
View File
@@ -1,9 +1,20 @@
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
# store. The install step resolves from the baked store (--prefer-offline)
# instead of paying a ~731s cold fetch + native compile every run.
variables:
- &node_image 'node:22-alpine'
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
when:
- event: [push, pull_request, manual]
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
# push CI is restricted to protected branches (main) so a feature-branch push no
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
# CI load on the storage-constrained runner with zero loss of gating (branch
# protection requires no push/ci status context; main still gets full push CI).
- event: [pull_request, manual]
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
@@ -15,8 +26,21 @@ steps:
image: *node_image
commands:
- corepack enable
- apk add --no-cache python3 make g++
- pnpm install --frozen-lockfile
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
# the baked pnpm store.
- pnpm install --frozen-lockfile --prefer-offline
# Blocking gate: public framework package must contain no operator-specific
# personal data or private $HOME defaults. Runs early (no node_modules needed).
sanitization:
image: *node_image
commands:
- apk add --no-cache bash
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
# Resident line-count ceiling over framework-owned resident files
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
typecheck:
image: *node_image
@@ -25,6 +49,7 @@ steps:
- pnpm typecheck
depends_on:
- install
- sanitization
# lint, format, and test are independent — run in parallel after typecheck
lint:
@@ -46,18 +71,27 @@ steps:
test:
image: *node_image
environment:
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
# Avoid the namespace-level Woodpecker DB service named "postgres".
# The Kubernetes backend exposes service containers by step name.
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands:
- *enable_pnpm
# Install postgresql-client for pg_isready
- apk add --no-cache postgresql-client
# Wait up to 30s for postgres to be ready
# postgresql-client (pg_isready) is baked into ci-base.
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- |
for i in $(seq 1 30); do
pg_isready -h postgres -p 5432 -U mosaic && break
echo "Waiting for postgres ($i/30)..."
ready=0
for i in $(seq 1 60); do
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
ready=1
break
fi
echo "Waiting for ci-postgres ($i/60)..."
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "ci-postgres did not become ready" >&2
exit 1
fi
# Run migrations (DATABASE_URL is set in environment above)
- pnpm --filter @mosaicstack/db run db:migrate
# Run all tests
@@ -66,7 +100,7 @@ steps:
- typecheck
services:
postgres:
ci-postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_USER: mosaic
+59 -2
View File
@@ -2,8 +2,27 @@
# Runs only on main branch push/tag
variables:
- &node_image 'node:22-alpine'
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
# toolchain + warm pnpm store. Kills the second cold install publish pays.
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
# (tags) always build everything. Exclude-list keeps the default SAFE: any
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
- &image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main]
@@ -14,7 +33,8 @@ steps:
image: *node_image
commands:
- corepack enable
- pnpm install --frozen-lockfile
# Resolve from the baked pnpm store instead of a cold network fetch.
- pnpm install --frozen-lockfile --prefer-offline
build:
image: *node_image
@@ -26,6 +46,15 @@ steps:
publish-npm:
image: *node_image
# Publish only when a publishable package changed (or on a release tag); a
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'packages/**'
environment:
NPM_TOKEN:
from_secret: gitea_token
@@ -91,6 +120,7 @@ steps:
build-gateway:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
@@ -114,8 +144,35 @@ steps:
depends_on:
- build
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
depends_on:
- build
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
+45
View File
@@ -0,0 +1,45 @@
# Pre-baked CI base image for Woodpecker pipelines.
#
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
# already fetched at build time. `pnpm fetch` only populates the store from the
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
# which is exactly why the musl toolchain stays baked into this image. A
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
# tarballs from local hard-links (no network) and compiles natives against the
# already-present toolchain, in tens of seconds instead of ~731s.
#
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
# (see .woodpecker/ci-image.yml).
#
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
# carries zero cache variables. node:26 stays held until it reaches LTS
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
# `apk add bash`.
RUN apk add --no-cache python3 make g++ postgresql-client bash
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /app
# Pin the store location so the pipeline can point `store-dir` at the same path.
ENV PNPM_HOME=/root/.local/share/pnpm
RUN pnpm config set store-dir /root/.local/share/pnpm/store
# Warm the store. `pnpm fetch` populates the content-addressable store with the
# dependency tarballs directly from the lockfile (no package.json / workspace
# needed), so a baked store stays valid until the lockfile changes. Note:
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
# in the pipeline, against the toolchain baked above.
COPY pnpm-lock.yaml ./
RUN pnpm fetch --frozen-lockfile
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mosaic Stack
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2
View File
@@ -58,6 +58,8 @@ mosaic yolo pi # Pi in yolo mode
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
### TUI & Gateway
```bash
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@mosaicstack/mosaic-as",
"version": "0.0.1",
"type": "module",
"private": true,
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "apps/appservice"
},
"main": "dist/main.js",
"bin": {
"mosaic-as": "dist/main.js",
"mosaic-as-registration": "dist/registration-main.js"
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"dev": "tsx watch src/main.ts"
},
"dependencies": {
"@mosaicstack/appservice": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,388 @@
import { describe, expect, it, vi } from 'vitest';
import { AppserviceDaemon } from '../server.js';
import type { DaemonConfig, DaemonRequest } from '../server.js';
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
const cfg: DaemonConfig = {
homeserverUrl: 'https://hs.example',
domain: 'hs.example',
asToken: 'as-secret',
hsToken: 'hs-secret',
bridgeTokens: ['bridge-secret'],
};
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const request = (overrides: Partial<DaemonRequest>): DaemonRequest => ({
method: 'GET',
path: '/',
searchParams: new URLSearchParams(),
body: undefined,
...overrides,
});
const makeDaemon = () => {
const fetchMock = vi.fn(async (_input: URL | string) => jsonResponse(200, { event_id: '$sent' }));
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
return { daemon, fetchMock };
};
describe('AppserviceDaemon routing', () => {
it('serves health unauthenticated', async () => {
const { daemon } = makeDaemon();
expect((await daemon.handle(request({ path: '/health' }))).status).toBe(200);
});
it('404s unknown paths', async () => {
const { daemon } = makeDaemon();
expect((await daemon.handle(request({ path: '/nope' }))).status).toBe(404);
});
it('transactions require the hs_token', async () => {
const { daemon } = makeDaemon();
const bad = await daemon.handle(
request({
method: 'PUT',
path: '/_matrix/app/v1/transactions/t1',
authorizationHeader: 'Bearer wrong',
body: { events: [] },
}),
);
expect(bad.status).toBe(403);
const ok = await daemon.handle(
request({
method: 'PUT',
path: '/_matrix/app/v1/transactions/t1',
authorizationHeader: 'Bearer hs-secret',
body: { events: [{ type: 'm.room.message', event_id: '$e' }] },
}),
);
expect(ok.status).toBe(200);
});
it('bridge requires a bridge token (hs/as tokens do not work)', async () => {
const { daemon } = makeDaemon();
for (const token of [undefined, 'Bearer hs-secret', 'Bearer as-secret', 'Bearer nope']) {
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: token,
body: {},
}),
);
expect(res.status).toBe(403);
}
});
it('bridge message sends as the agent and returns the event id', async () => {
const { daemon, fetchMock } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: 'Bearer bridge-secret',
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi', thread_root: '$req' },
}),
);
expect(res.status).toBe(200);
expect(res.body.event_id).toBe('$sent');
const sendCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.includes('/send/m.room.message/'));
expect(sendCall).toBeDefined();
expect(sendCall!.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example');
});
it('bridge rejects invalid payloads with 400', async () => {
const { daemon } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: 'Bearer bridge-secret',
body: { room_id: 'bad', agent: 'pi0', body: 'x' },
}),
);
expect(res.status).toBe(400);
});
it('bridge typing endpoint works', async () => {
const { daemon, fetchMock } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/typing',
authorizationHeader: 'Bearer bridge-secret',
body: { room_id: '!r:hs.example', agent: 'pi0-web1', typing: true },
}),
);
expect(res.status).toBe(200);
const typingCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.includes('/typing/'));
expect(typingCall).toBeDefined();
});
it('authenticated unknown bridge sub-paths return 405, never fall through', async () => {
const { daemon } = makeDaemon();
const res = await daemon.handle(
request({
method: 'GET',
path: '/bridge/v1/unknown',
authorizationHeader: 'Bearer bridge-secret',
}),
);
expect(res.status).toBe(405);
});
it('provisions a room as the AS sender with space linking', async () => {
const calls: Array<{ url: URL; body: unknown }> = [];
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
const url = new URL(String(input));
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
if (url.pathname.endsWith('/createRoom'))
return jsonResponse(200, { room_id: '!new:hs.example' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: {
name: 'proj-x',
alias: 'mosaic-proj-x',
invite: ['@jason.woltje:hs.example'],
space_id: '!space:hs.example',
},
}),
);
expect(res.status).toBe(200);
expect(res.body.room_id).toBe('!new:hs.example');
expect(res.body.space_linked).toBe(true);
const create = calls.find((c) => c.url.pathname.endsWith('/createRoom'));
expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example');
const body = create!.body as Record<string, unknown>;
expect(body.room_alias_name).toBe('mosaic-proj-x');
expect((body.power_level_content_override as Record<string, unknown>).users).toEqual({
'@mosaic-as:hs.example': 100,
});
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true);
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true);
});
it('space-link failure still returns the room id (no orphan)', async () => {
const fetchMock = vi.fn(async (input: URL | string) => {
const url = new URL(String(input));
if (url.pathname.endsWith('/createRoom'))
return jsonResponse(200, { room_id: '!new:hs.example' });
if (url.pathname.includes('/state/m.space.child/'))
return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: { name: 'proj-x', space_id: '!space:hs.example' },
}),
);
expect(res.status).toBe(200);
expect(res.body.room_id).toBe('!new:hs.example');
expect(res.body.space_linked).toBe(false);
expect(String(res.body.space_error)).toContain('403');
});
it('invite list cap enforced', async () => {
const { daemon } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) },
}),
);
expect(res.status).toBe(400);
});
it('provision rejects bad payloads and requires auth', async () => {
const { daemon } = makeDaemon();
const noAuth = await daemon.handle(
request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }),
);
expect(noAuth.status).toBe(403);
const bad = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: { name: '', alias: 'BAD ALIAS' },
}),
);
expect(bad.status).toBe(400);
});
// A daemon whose fetch mock backs account_data with a mutable in-test object,
// so register/verify/revoke round-trip through the (faked) homeserver.
const makeAgentDaemon = () => {
const accountData: { value: Record<string, unknown> | null } = { value: null };
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
const url = new URL(String(input));
const path = url.pathname;
if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
if (init?.method === 'PUT') {
accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
return jsonResponse(200, {});
}
if (accountData.value === null) {
return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
}
return jsonResponse(200, accountData.value);
}
if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
return { daemon, fetchMock };
};
const registerAgent = async (
daemon: AppserviceDaemon,
body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
) =>
daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents',
authorizationHeader: 'Bearer bridge-secret',
body,
}),
);
it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
const { daemon, fetchMock } = makeAgentDaemon();
const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
expect(res.status).toBe(200);
expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
const registerCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.endsWith('/register'));
expect(registerCall).toBeDefined();
});
it('register requires a HOST token (agent token and no token are 403)', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon);
const agentToken = String(minted.body.bridge_token);
const asAgent = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents',
authorizationHeader: `Bearer ${agentToken}`,
body: { alias: 'pi1', host: 'web2' },
}),
);
expect(asAgent.status).toBe(403);
const noAuth = await daemon.handle(
request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
);
expect(noAuth.status).toBe(403);
});
it('agent-scoped token may send as itself but not as another agent', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
const agentToken = String(minted.body.bridge_token);
const self = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
}),
);
expect(self.status).toBe(200);
const other = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
}),
);
expect(other.status).toBe(403);
expect(other.body.error).toBe('token not scoped to this agent');
});
it('revoked agent token is rejected on messages', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
const agentToken = String(minted.body.bridge_token);
const revoke = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents/revoke',
authorizationHeader: 'Bearer bridge-secret',
body: { agent_user_id: '@agent-pi0-web1:hs.example' },
}),
);
expect(revoke.status).toBe(200);
expect(revoke.body.revoked).toBe(1);
const afterRevoke = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
}),
);
expect(afterRevoke.status).toBe(403);
});
it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
const { daemon } = makeAgentDaemon();
await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });
const res = await daemon.handle(
request({
method: 'GET',
path: '/bridge/v1/agents',
authorizationHeader: 'Bearer bridge-secret',
}),
);
expect(res.status).toBe(200);
const agents = res.body.agents as Array<Record<string, unknown>>;
expect(agents).toHaveLength(1);
expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
expect(agents[0]?.display_name).toBe('Pi Zero');
});
it('empty bridge token list denies everything', async () => {
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/typing',
authorizationHeader: 'Bearer bridge-secret',
body: {},
}),
);
expect(res.status).toBe(403);
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { DaemonConfig } from './server.js';
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`missing required env var ${name}`);
return value;
};
export function configFromEnv(): DaemonConfig & { port: number } {
return {
homeserverUrl: required('MOSAIC_AS_HOMESERVER_URL'),
domain: required('MOSAIC_AS_DOMAIN'),
asToken: required('MOSAIC_AS_TOKEN'),
hsToken: required('MOSAIC_HS_TOKEN'),
userPrefix: process.env.MOSAIC_AS_USER_PREFIX ?? 'agent-',
senderLocalpart: process.env.MOSAIC_AS_SENDER_LOCALPART ?? 'mosaic-as',
bridgeTokens: (process.env.MOSAIC_AS_BRIDGE_TOKENS ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean),
port: Number(process.env.MOSAIC_AS_PORT ?? 8008),
};
}
+67
View File
@@ -0,0 +1,67 @@
import http from 'node:http';
import { configFromEnv } from './config.js';
import { AppserviceDaemon } from './server.js';
const cfg = configFromEnv();
const daemon = new AppserviceDaemon(cfg);
const MAX_BODY_BYTES = 1024 * 1024;
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
let received = 0;
let rejected = false;
req.on('data', (chunk: Buffer) => {
received += chunk.length;
if (received > MAX_BODY_BYTES) {
rejected = true;
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'request body too large' }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (rejected) return;
void (async () => {
const url = new URL(req.url ?? '/', 'http://localhost');
let body: unknown;
try {
const raw = Buffer.concat(chunks).toString();
body = raw ? JSON.parse(raw) : undefined;
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ errcode: 'M_NOT_JSON', error: 'invalid json' }));
return;
}
const result = await daemon.handle({
method: req.method ?? 'GET',
path: url.pathname,
searchParams: url.searchParams,
authorizationHeader: req.headers.authorization,
body,
});
res.writeHead(result.status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result.body));
})().catch((error: unknown) => {
console.error('request failed:', error);
if (res.headersSent) {
res.destroy();
return;
}
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'internal error' }));
});
});
});
server.listen(cfg.port, () => {
console.log(
`mosaic-as listening on :${cfg.port} (homeserver ${cfg.homeserverUrl}, domain ${cfg.domain})`,
);
if (cfg.bridgeTokens.length === 0) {
console.warn('WARNING: MOSAIC_AS_BRIDGE_TOKENS is empty — bridge API will deny all requests');
}
});
+10
View File
@@ -0,0 +1,10 @@
import { buildRegistration, registrationToYaml } from '@mosaicstack/appservice';
import { configFromEnv } from './config.js';
// Prints the Synapse registration YAML (mosaic-as.yaml) for the current env.
// Usage: MOSAIC_AS_URL=http://mosaic-as:8008 mosaic-as-registration > mosaic-as.yaml
const cfg = configFromEnv();
const url = process.env.MOSAIC_AS_URL;
if (!url) throw new Error('missing required env var MOSAIC_AS_URL');
process.stdout.write(registrationToYaml(buildRegistration(cfg, { url })));
+225
View File
@@ -0,0 +1,225 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import {
AgentTokenStore,
AppserviceIntent,
TransactionHandler,
validateBridgeMessage,
validateBridgeTyping,
validateProvisionRoom,
validateRegisterAgent,
validateRevokeAgent,
} from '@mosaicstack/appservice';
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
export interface DaemonConfig extends AppserviceConfig {
/** Bearer tokens accepted on /bridge/v1/* (one per agent-comms host daemon). */
bridgeTokens: string[];
}
export interface DaemonRequest {
method: string;
/** URL path without query string. */
path: string;
searchParams: URLSearchParams;
authorizationHeader?: string;
body: unknown;
}
export interface DaemonResponse {
status: number;
body: Record<string, unknown>;
}
// Compare equal-length HMAC digests so neither content nor LENGTH of the
// stored secret is observable through timing.
const HMAC_KEY = randomBytes(32);
const digest = (value: string): Buffer => createHmac('sha256', HMAC_KEY).update(value).digest();
const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
/**
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
* to a single virtual user and may only act as themselves.
*/
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
/**
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
* Application Service transactions endpoint (Synapse-facing) plus the
* internal bridge API v1 (agent-comms daemon-facing). main.ts binds this to
* node:http; tests drive it directly.
*/
export class AppserviceDaemon {
readonly intent: AppserviceIntent;
private readonly transactions: TransactionHandler;
private readonly agents: AgentTokenStore;
constructor(
private readonly cfg: DaemonConfig,
fetchImpl?: typeof fetch,
private readonly log: (line: string) => void = (line) => console.log(line),
) {
this.intent = new AppserviceIntent(cfg, fetchImpl);
this.agents = new AgentTokenStore(this.intent);
this.transactions = new TransactionHandler({
hsToken: cfg.hsToken,
onEvent: (event) => this.onEvent(event),
onError: (error, txnId) => this.log(`txn ${txnId} handler error: ${String(error)}`),
});
}
/** v1: the daemon only observes; room logic lives in the agent-comms daemons. */
private onEvent(event: MatrixEvent): void {
if (event.type === 'm.room.message') {
this.log(
`event ${event.event_id ?? '?'} in ${event.room_id ?? '?'} from ${event.sender ?? '?'}`,
);
}
}
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
* up in the agent token store; anything else is rejected. */
private async bridgeAuthorized(
authorizationHeader: string | undefined,
): Promise<BridgePrincipal> {
if (!authorizationHeader?.startsWith('Bearer ')) return null;
const presented = authorizationHeader.slice('Bearer '.length);
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
return { kind: 'host' };
}
const agentUserId = await this.agents.verifyToken(presented);
if (agentUserId) return { kind: 'agent', agentUserId };
return null;
}
async handle(req: DaemonRequest): Promise<DaemonResponse> {
if (req.method === 'GET' && req.path === '/health') {
return { status: 200, body: { ok: true } };
}
const txnMatch = req.method === 'PUT' ? TXN_PATH.exec(req.path) : null;
if (txnMatch?.[1] !== undefined) {
return this.transactions.handle(txnMatch[1], req.body, {
authorizationHeader: req.authorizationHeader,
accessTokenParam: req.searchParams.get('access_token') ?? undefined,
});
}
if (req.path.startsWith('/bridge/v1/')) {
const principal = await this.bridgeAuthorized(req.authorizationHeader);
if (!principal) {
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
}
try {
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
};
}
validateRegisterAgent(req.body);
const { agentUserId, token } = await this.agents.register({
alias: req.body.alias,
host: req.body.host,
displayName: req.body.display_name,
});
this.log(`registered agent ${agentUserId}`);
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
};
}
validateRevokeAgent(req.body);
const revoked = await this.agents.revoke(req.body.agent_user_id);
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
return { status: 200, body: { revoked } };
}
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
};
}
const agents = await this.agents.list();
return { status: 200, body: { agents } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
validateBridgeMessage(req.body);
if (
principal.kind === 'agent' &&
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
) {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
};
}
const eventId = await this.intent.sendAsAgent({
roomId: req.body.room_id,
agent: req.body.agent,
body: req.body.body,
threadRoot: req.body.thread_root,
msgtype: req.body.msgtype,
extraContent: req.body.extra_content,
});
return { status: 200, body: { event_id: eventId ?? null } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
validateBridgeTyping(req.body);
if (
principal.kind === 'agent' &&
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
) {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
};
}
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
return { status: 200, body: {} };
}
if (req.method === 'POST' && req.path === '/bridge/v1/provision/rooms') {
validateProvisionRoom(req.body);
const result = await this.intent.createRoom({
name: req.body.name,
alias: req.body.alias,
topic: req.body.topic,
invite: req.body.invite,
spaceId: req.body.space_id,
});
this.log(
`provisioned room ${result.roomId} (${req.body.name}) space_linked=${result.spaceLinked}`,
);
return {
status: 200,
body: {
room_id: result.roomId,
space_linked: result.spaceLinked,
...(result.spaceError ? { space_error: result.spaceError } : {}),
},
};
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log(`bridge error ${req.method} ${req.path}: ${message}`);
return { status: 400, body: { error: message } };
}
// Explicit: never fall out of the authenticated bridge block, so future
// sub-paths cannot accidentally route around the auth guard above.
return { status: 405, body: { error: 'unsupported bridge method/path' } };
}
return { status: 404, body: { error: 'not found' } };
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+2
View File
@@ -31,6 +31,7 @@
"@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@mosaicstack/agent": "workspace:^",
"@mosaicstack/auth": "workspace:^",
"@mosaicstack/brain": "workspace:^",
"@mosaicstack/config": "workspace:^",
@@ -73,6 +74,7 @@
"rxjs": "^7.8.0",
"socket.io": "^4.8.0",
"uuid": "^11.0.0",
"undici": "^7.24.6",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
import {
createDiscordIngressEnvelope,
DiscordPlugin,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import { InteractionController } from '../../agent/interaction.controller.js';
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
import { DurableSessionService } from '../../agent/durable-session.service.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
const SERVICE_TOKEN = 'test-discord-service-token';
const envKeys = [
'DISCORD_SERVICE_TOKEN',
'DISCORD_SERVICE_TENANT_ID',
'DISCORD_INTERACTION_BINDINGS',
'DISCORD_ALLOWED_GUILD_IDS',
'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
] as const;
const priorEnv = new Map<string, string | undefined>();
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
return {
content,
messageId,
correlationId,
guildId: 'guild-1',
channelId: 'channel-1',
userId: 'discord-admin-1',
conversationId: 'Nova:discord:channel-1',
};
}
function authorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
{
select: () => ({
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
}),
} as never,
{
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => entries.set(key, value),
del: async (key: string) => Number(entries.delete(key)),
},
);
}
describe('interaction Discord/CLI durable-session integration', () => {
afterEach(() => {
for (const key of envKeys) {
const value = priorEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
priorEnv.clear();
});
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
for (const key of envKeys) priorEnv.set(key, process.env[key]);
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
]);
const durable = new DurableSessionService(
new InMemoryDurableSessionStore() as never,
{} as never,
);
const enrollmentRuntime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const controller = new InteractionController(enrollmentRuntime as never, durable);
await controller.enroll(
'Nova',
'Nova:discord:channel-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
'cli-enrollment-correlation',
);
const authz = authorization();
const terminated = vi.fn().mockResolvedValue(undefined);
const runtime = new RuntimeProviderService(
{
require: () => ({
capabilities: async () => ({ supported: ['session.terminate'] }),
terminate: terminated,
}),
} as never,
{ record: async () => undefined } as never,
{
consume: (approvalId, action) =>
authz.consumeRuntimeTerminationApproval(approvalId, action),
},
);
const gateway = new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
authz,
runtime,
durable,
);
const client = { data: { discordService: true }, emit: vi.fn() };
const plugin = new DiscordPlugin({
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-1'],
allowedChannelIds: ['channel-1'],
allowedUserIds: ['discord-admin-1'],
interactionBindings: [
{
instanceId: 'Nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
],
});
const pluginInternals = plugin as unknown as {
client: { user: { id: string } };
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
handleDiscordMessage(message: unknown): void;
};
const pluginSocket = { connected: true, emit: vi.fn() };
pluginInternals.client = { user: { id: 'bot-1' } };
pluginInternals.socket = pluginSocket;
pluginInternals.handleDiscordMessage({
id: 'approve-1',
guildId: 'guild-1',
channelId: 'channel-1',
author: { id: 'discord-admin-1', bot: false },
mentions: { has: () => true },
content: '<@bot-1> /approve',
channel: { parentId: null },
attachments: new Map(),
});
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
success: boolean;
};
expect(approval.success).toBe(true);
await gateway.handleDiscordStop(
client as never,
createDiscordIngressEnvelope(
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
SERVICE_TOKEN,
),
);
expect(terminated).toHaveBeenCalledWith(
'runtime-1',
approval.approvalId,
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
);
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
correlationId: 'discord-stop-correlation',
success: true,
});
});
});
@@ -20,7 +20,7 @@ export class AdminHealthController {
async check(): Promise<HealthStatusDto> {
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
const sessions = this.agentService.listSessions();
const sessions = this.agentService.listAllSessionsForSystem();
const providers = this.providerService.listProviders();
const allOk = database.status === 'ok' && cache.status === 'ok';
@@ -0,0 +1,179 @@
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { AgentService, type AgentSession } from '../agent.service.js';
import type { ActorTenantScope } from '../../auth/session-scope.js';
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
type AgentServiceInternals = {
sessions: Map<string, AgentSession>;
creating: Map<string, Promise<AgentSession>>;
};
function makeService(operatorMemory: unknown = null): AgentService {
return new AgentService(
{
getDefaultModel: vi.fn(() => null),
getRegistry: vi.fn(() => ({})),
findModel: vi.fn(),
listAvailableModels: vi.fn(() => []),
} as never,
{} as never,
{} as never,
{ available: false } as never,
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
);
}
function internals(service: AgentService): AgentServiceInternals {
return service as unknown as AgentServiceInternals;
}
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp/tess-session-ownership-test',
allowedTools: null,
userId: scope.userId,
tenantId: scope.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
describe('AgentService owner/tenant scope enforcement', () => {
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
const service = makeService();
const session = makeSession();
internals(service).sessions.set(CONVERSATION_ID, session);
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
expect(session.channels.has('websocket:owner')).toBe(true);
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() =>
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
expect(session.modelId).toBe('owner-model');
expect(() =>
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
expect(session.agentConfigId).toBe('agent-owner');
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
cleanup();
await expect(
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
expect(session.piSession.dispose).toHaveBeenCalled();
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
});
it('derives the operator-memory scope on the createSession production path', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
// Session construction reaches the real scope derivation before the intentionally incomplete
// Pi test double rejects later in createAgentSession.
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
tenantId: OWNER_SCOPE.tenantId,
ownerId: OWNER_SCOPE.userId,
sessionId: CONVERSATION_ID,
});
});
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
internals(service).sessions.set(CONVERSATION_ID, makeSession());
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(buildTools).not.toHaveBeenCalled();
expect(plugin.capture).not.toHaveBeenCalled();
expect(plugin.search).not.toHaveBeenCalled();
});
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
const service = makeService();
const session = makeSession();
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
await expect(
service.createSession(CONVERSATION_ID, {
userId: FOREIGN_SCOPE.userId,
tenantId: FOREIGN_SCOPE.tenantId,
}),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
service.createSession(CONVERSATION_ID, {
userId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
}),
).resolves.toBe(session);
});
});
@@ -0,0 +1,370 @@
import { describe, expect, it } from 'vitest';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import type { ActorTenantScope } from '../../auth/session-scope.js';
import {
RuntimeProviderAuditService,
RuntimeProviderService,
type RuntimeAuditEvent,
type RuntimeAuditSink,
type RuntimeApprovalVerifier,
} from '../runtime-provider-registry.service.js';
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
const CONTEXT = {
actorScope: OWNER_SCOPE,
channelId: 'cli',
correlationId: 'correlation-1',
};
class RecordingRuntimeProvider implements AgentRuntimeProvider {
readonly id = 'fleet';
readonly receivedScopes: RuntimeScope[] = [];
readonly sentMessages: RuntimeMessage[] = [];
terminateCalls = 0;
throwAfterSend = false;
throwAuthorization = false;
constructor(private readonly supported: RuntimeCapability[]) {}
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
this.receivedScopes.push(scope);
return { supported: this.supported };
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
this.receivedScopes.push(scope);
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
this.receivedScopes.push(scope);
return [];
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
this.receivedScopes.push(scope);
return [];
}
async *streamSession(
_sessionId: string,
_cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
this.receivedScopes.push(scope);
return;
}
async sendMessage(
_sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
this.receivedScopes.push(scope);
this.sentMessages.push(message);
if (this.throwAuthorization) {
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
}
if (this.throwAfterSend) {
throw new Error('provider acknowledgement failed');
}
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
this.receivedScopes.push(scope);
return {
attachmentId: 'attachment-1',
sessionId,
mode,
expiresAt: '2026-07-12T00:00:00.000Z',
};
}
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
}
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
this.terminateCalls += 1;
}
}
class RecordingAuditSink implements RuntimeAuditSink {
readonly events: RuntimeAuditEvent[] = [];
async record(event: RuntimeAuditEvent): Promise<void> {
this.events.push(event);
}
}
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
async consume(): Promise<boolean> {
return false;
}
}
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
async consume(
_approvalRef: string,
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
): Promise<boolean> {
this.consumedAction = action;
return true;
}
}
function makeService(
provider: RecordingRuntimeProvider,
audit: RuntimeAuditSink = new RecordingAuditSink(),
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
): RuntimeProviderService {
const registry = new AgentRuntimeProviderRegistry();
registry.register(provider);
return new RuntimeProviderService(registry, audit, approval);
}
describe('RuntimeProviderService security boundary', (): void => {
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
);
const providerScope = provider.receivedScopes[0];
expect(providerScope).toEqual({
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
});
expect(Object.isFrozen(providerScope)).toBe(true);
expect(audit.events).toContainEqual(
expect.objectContaining({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
resourceId: 'session-1',
durationMs: expect.any(Number),
}),
);
expect(JSON.stringify(audit.events)).not.toContain('hello');
expect(JSON.stringify(audit.events)).not.toContain('key-1');
});
it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let persisted: unknown;
const durableAudit = new RuntimeProviderAuditService({
logs: {
ingest: async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
},
},
} as never);
const service = makeService(provider, durableAudit);
await service.sendMessage(
'fleet',
'session/credential-canary=secret-value',
{ content: 'safe message', idempotencyKey: 'key-1' },
CONTEXT,
);
expect(provider.sentMessages).toHaveLength(1);
expect(JSON.stringify(persisted)).not.toContain('secret-value');
});
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider([]);
const service = makeService(provider);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/capability denied/);
expect(provider.sentMessages).toEqual([]);
});
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new DenyingApprovalVerifier();
const audit = new RecordingAuditSink();
const service = makeService(provider, audit, approval);
await expect(
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
).rejects.toThrow(/approval denied/);
expect(provider.terminateCalls).toBe(0);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new AcceptingApprovalVerifier();
const service = makeService(provider, new RecordingAuditSink(), approval);
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
expect(approval.consumedAction).toEqual({
providerId: 'fleet',
sessionId: 'session-1',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
agentName: process.env['MOSAIC_AGENT_NAME'],
});
expect(provider.terminateCalls).toBe(1);
});
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const unavailableAudit: RuntimeAuditSink = {
async record(): Promise<void> {
throw new Error('audit unavailable');
},
};
const service = makeService(provider, unavailableAudit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/audit unavailable/);
expect(provider.sentMessages).toEqual([]);
});
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAfterSend = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider acknowledgement failed/);
expect(provider.sentMessages).toHaveLength(1);
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
'requested',
'failed',
]);
expect(audit.events.at(-1)).toMatchObject({
errorCode: 'provider_error',
durationMs: expect.any(Number),
});
});
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAuthorization = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider authorization denied/);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
let persisted: unknown;
const ingest = async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
};
const service = new RuntimeProviderAuditService({ logs: { ingest } } as never);
await service.record({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
resourceId: 'session-1',
durationMs: 12,
});
expect(persisted).toMatchObject({
content: 'runtime.provider.audit',
metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }),
});
expect(JSON.stringify(persisted)).not.toContain('approval');
});
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let auditCalls = 0;
const audit: RuntimeAuditSink = {
async record(): Promise<void> {
auditCalls += 1;
if (auditCalls === 2) {
throw new Error('completion audit unavailable');
}
},
};
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).resolves.toBeUndefined();
expect(provider.sentMessages).toHaveLength(1);
expect(auditCalls).toBe(2);
});
});
@@ -0,0 +1,262 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
vi.mock('../../commands/command-executor.service.js', () => ({
CommandExecutorService: class CommandExecutorService {},
}));
vi.mock('../routing/routing-engine.service.js', () => ({
RoutingEngineService: class RoutingEngineService {},
}));
import { SessionsController } from '../sessions.controller.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
promptCount: 0,
channels: [],
durationMs: 0,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
...overrides,
};
}
function makeAgentSession(owner = USER_A): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp',
allowedTools: null,
userId: owner.id,
tenantId: owner.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
),
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
),
destroySession: vi.fn(),
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
recordMessage: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
}
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain('listSessions(scope: ActorTenantScope)');
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain(
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
);
expect(source).toContain('scope: ActorTenantScope,');
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
expect(source).not.toContain('scope?: ActorTenantScope');
});
});
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
expect(agentService.listSessions).toHaveBeenCalledWith({
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not reveal another owner/tenant session by guessed id', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not terminate another owner/tenant session by guessed id', async () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
NotFoundException,
);
expect(agentService.destroySession).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
const agentService = makeScopedAgentService();
const controller = new ChatController(agentService as never);
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.prompt).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
function makeGateway(agentService = makeScopedAgentService()) {
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
const commandExecutor = { execute: vi.fn() };
const routingEngine = {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
commandRegistry as never,
commandExecutor as never,
routingEngine as never,
);
return { gateway, agentService };
}
function makeSocket() {
return {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
}
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
});
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.onEvent).not.toHaveBeenCalled();
expect(agentService.addChannel).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
});
+48 -2
View File
@@ -1,4 +1,5 @@
import { Global, Module } from '@nestjs/common';
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
@@ -8,24 +9,66 @@ import { SkillLoaderService } from './skill-loader.service.js';
import { ProvidersController } from './providers.controller.js';
import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js';
import { InteractionController } from './interaction.controller.js';
import { RoutingController } from './routing/routing.controller.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
const registry = new AgentRuntimeProviderRegistry();
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
return registry;
}
@Global()
@Module({
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
providers: [
ProviderService,
ProviderCredentialsService,
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionRepository,
DurableSessionService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
},
RuntimeProviderAuditService,
{
provide: RUNTIME_PROVIDER_AUDIT_SINK,
useExisting: RuntimeProviderAuditService,
},
{
provide: RUNTIME_APPROVAL_VERIFIER,
useExisting: CommandRuntimeApprovalVerifier,
},
RuntimeProviderService,
AgentService,
],
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
controllers: [
ProvidersController,
SessionsController,
AgentConfigsController,
InteractionController,
RoutingController,
],
exports: [
AgentService,
ProviderService,
@@ -33,6 +76,9 @@ import { GCModule } from '../gc/gc.module.js';
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionService,
RuntimeProviderService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],
})
export class AgentModule {}
+133 -47
View File
@@ -1,4 +1,11 @@
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
import {
ForbiddenException,
Inject,
Injectable,
Logger,
Optional,
type OnModuleDestroy,
} from '@nestjs/common';
import {
createAgentSession,
DefaultResourceLoader,
@@ -8,9 +15,10 @@ import {
type ToolDefinition,
} from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaicstack/brain';
import type { Memory } from '@mosaicstack/memory';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js';
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.service.js';
import { ProviderService } from './provider.service.js';
@@ -28,6 +36,7 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
import { PreferencesService } from '../preferences/preferences.service.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
/** A single message from DB conversation history, used for context injection. */
export interface ConversationHistoryMessage {
@@ -68,6 +77,8 @@ export interface AgentSessionOptions {
agentConfigId?: string;
/** ID of the user who owns this session. Used for preferences and system override lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/**
* Prior conversation messages to inject as context when resuming a session.
* These messages are formatted and prepended to the system prompt so the
@@ -94,6 +105,8 @@ export interface AgentSession {
allowedTools: string[] | null;
/** User ID that owns this session, used for preference lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/** Agent config ID applied to this session, if any (M5-001). */
agentConfigId?: string;
/** Human-readable agent name applied to this session, if any (M5-001). */
@@ -123,6 +136,9 @@ export class AgentService implements OnModuleDestroy {
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@Inject(SessionGCService) private readonly gc: SessionGCService,
@Optional()
@Inject(OPERATOR_MEMORY_PLUGIN)
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
) {}
/**
@@ -134,6 +150,7 @@ export class AgentService implements OnModuleDestroy {
private buildToolsForSandbox(
sandboxDir: string,
sessionUserId: string | undefined,
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
): ToolDefinition[] {
return [
...createBrainTools(this.brain),
@@ -142,6 +159,9 @@ export class AgentService implements OnModuleDestroy {
this.memory,
this.embeddingService.available ? this.embeddingService : null,
sessionUserId,
this.operatorMemory && sessionScope
? { plugin: this.operatorMemory, scope: sessionScope }
: undefined,
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
@@ -174,12 +194,20 @@ export class AgentService implements OnModuleDestroy {
.filter((t) => t.length > 0);
}
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
const scope = this.scopeFromOptions(options);
const existing = this.sessions.get(sessionId);
if (existing) return existing;
if (existing) {
this.assertSessionScope(existing, scope);
return existing;
}
const inflight = this.creating.get(sessionId);
if (inflight) return inflight;
if (inflight) {
const session = await inflight;
this.assertSessionScope(session, scope);
return session;
}
const promise = this.doCreateSession(sessionId, options).finally(() => {
this.creating.delete(sessionId);
@@ -208,6 +236,7 @@ export class AgentService implements OnModuleDestroy {
isAdmin: options.isAdmin,
agentConfigId: options.agentConfigId,
userId: options.userId,
tenantId: options.tenantId,
conversationHistory: options.conversationHistory,
};
this.logger.log(
@@ -247,7 +276,15 @@ export class AgentService implements OnModuleDestroy {
}
// Build per-session tools scoped to the sandbox directory and authenticated user
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
const sessionUserId = mergedOptions?.userId;
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
const sandboxTools = this.buildToolsForSandbox(
sandboxDir,
sessionUserId,
sessionUserId && sessionTenantId
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
: undefined,
);
// Combine static tools with dynamically discovered MCP client tools and skill tools
const mcpTools = this.mcpClientService.getToolDefinitions();
@@ -342,6 +379,7 @@ export class AgentService implements OnModuleDestroy {
sandboxDir,
allowedTools,
userId: mergedOptions?.userId,
tenantId: sessionTenantId,
agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName,
metrics: {
@@ -473,38 +511,70 @@ export class AgentService implements OnModuleDestroy {
return this.providerService.getDefaultModel() ?? null;
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
const session = this.sessions.get(sessionId);
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
return session;
}
listSessions(): SessionInfoDto[] {
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
provider: s.provider,
modelId: s.modelId,
...(s.agentName ? { agentName: s.agentName } : {}),
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: now - s.createdAt,
metrics: { ...s.metrics },
}));
return Array.from(this.sessions.values())
.filter((s) => this.sessionMatchesScope(s, scope))
.map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
listAllSessionsForSystem(): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
const s = this.sessions.get(sessionId);
if (!s) return undefined;
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
return this.toSessionInfo(s);
}
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
if (!options.userId) {
throw new ForbiddenException('Session owner scope is required');
}
return {
id: s.id,
provider: s.provider,
modelId: s.modelId,
...(s.agentName ? { agentName: s.agentName } : {}),
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: Date.now() - s.createdAt,
metrics: { ...s.metrics },
userId: options.userId,
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
};
}
private tenantIdFor(
userId: string | undefined,
tenantId: string | undefined,
): string | undefined {
return tenantId ?? userId;
}
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
return (
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
);
}
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
if (!this.sessionMatchesScope(session, scope)) {
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
}
}
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
return {
id: session.id,
provider: session.provider,
modelId: session.modelId,
...(session.agentName ? { agentName: session.agentName } : {}),
createdAt: new Date(session.createdAt).toISOString(),
promptCount: session.promptCount,
channels: Array.from(session.channels),
durationMs: now - session.createdAt,
metrics: { ...session.metrics },
};
}
@@ -553,9 +623,10 @@ export class AgentService implements OnModuleDestroy {
* not reconstructed — the model is used on the next createSession call for
* the same conversationId when the session is torn down or a new one is created.
*/
updateSessionModel(sessionId: string, modelId: string): void {
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
const prev = session.modelId;
session.modelId = modelId;
this.recordModelSwitch(sessionId);
@@ -572,48 +643,51 @@ export class AgentService implements OnModuleDestroy {
sessionId: string,
agentConfigId: string,
agentName: string,
scope: ActorTenantScope,
modelId?: string,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
session.agentConfigId = agentConfigId;
session.agentName = agentName;
if (modelId) {
this.updateSessionModel(sessionId, modelId);
this.updateSessionModel(sessionId, modelId, scope);
}
this.logger.log(
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
);
}
addChannel(sessionId: string, channel: string): void {
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.add(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.add(channel);
}
removeChannel(sessionId: string, channel: string): void {
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.delete(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.delete(channel);
}
async prompt(sessionId: string, message: string): Promise<void> {
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.promptCount += 1;
// Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = message;
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId);
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
await this.systemOverride.renew(sessionId);
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
}
@@ -629,16 +703,28 @@ export class AgentService implements OnModuleDestroy {
}
}
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
onEvent(
sessionId: string,
listener: (event: AgentSessionEvent) => void,
scope: ActorTenantScope,
): () => void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.listeners.add(listener);
return () => session.listeners.delete(listener);
}
async destroySession(sessionId: string): Promise<void> {
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
await this.destroySessionForSystem(sessionId);
}
private async destroySessionForSystem(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.logger.log(`Destroying agent session ${sessionId}`);
@@ -667,7 +753,7 @@ export class AgentService implements OnModuleDestroy {
async onModuleDestroy(): Promise<void> {
this.logger.log('Shutting down all agent sessions');
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
const results = await Promise.allSettled(stops);
for (const result of results) {
if (result.status === 'rejected') {
@@ -0,0 +1,10 @@
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
/** Server-side request for a replay-safe provider message. */
export interface ProviderOutboxDto {
sessionId: string;
idempotencyKey: string;
correlationId: string;
content: string;
context: RuntimeProviderRequestContext;
}
@@ -0,0 +1,416 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',
sessionId: 'tess-pglite-session',
tenantId: 'tenant-pglite',
ownerId: 'tess-owner',
providerId: 'fleet',
runtimeSessionId: 'nova',
};
describe('DurableSessionRepository', () => {
let dataDir: string | undefined;
let handle: DbHandle;
let previousAuthSecret: string | undefined;
beforeAll(async (): Promise<void> => {
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
await seedOwner(handle);
}, 30_000);
beforeEach(async (): Promise<void> => {
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
});
afterAll(async (): Promise<void> => {
await handle.close();
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
});
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await beforeRestart.create(IDENTITY);
await beforeRestart.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-before-kill',
correlationId: 'correlation-before-kill',
content: 'resume after a kill',
});
await beforeRestart.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-before-kill',
correlationId: 'correlation-before-kill',
channelId: 'cli',
kind: 'provider.send',
content: 'one response only',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-before-kill',
cursor: 'cursor-before-kill',
summary: 'restart-safe state',
compactionEpoch: 1,
});
await beforeRestart.handoff({
sessionId: IDENTITY.sessionId,
handoffId: 'handoff-before-kill',
destination: 'mos',
correlationId: 'correlation-before-kill',
checkpointId: 'checkpoint-before-kill',
status: 'pending',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-after-handoff',
cursor: 'cursor-after-handoff',
summary: 'newer state cannot strand the portable handoff',
compactionEpoch: 2,
});
await handle.close();
handle = createPgliteDb(dataDir!);
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const recovered = await afterRestart.recover(IDENTITY.sessionId);
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
const handled: string[] = [];
const effects: string[] = [];
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
expect(recovered.identity).toEqual(IDENTITY);
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
expect(handled).toEqual(['inbox-before-kill']);
expect(effects).toEqual(['outbox-before-kill']);
}, 30_000);
it('redacts sensitive durable payloads before persistence', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-inbox',
correlationId: 'correlation-redaction',
content: 'api_key=super-secret-canary',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-outbox',
correlationId: 'correlation-redaction',
channelId: 'cli',
kind: 'provider.send',
content: 'email [email protected] api_key=super-secret-canary',
});
await coordinator.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'redacted-checkpoint',
cursor: 'bearer super-secret-canary',
summary: 'email [email protected]',
compactionEpoch: 0,
});
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
const [persisted] = await handle.db
.select({ content: interactionInbox.content })
.from(interactionInbox)
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
expect(JSON.stringify(snapshot)).not.toContain('[email protected]');
expect(persisted?.content).not.toContain('super-secret-canary');
expect(persisted?.content).not.toContain('[REDACTED]');
}, 30_000);
it('fails closed when the configured idempotency secret is unavailable', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const secret = process.env['BETTER_AUTH_SECRET'];
delete process.env['BETTER_AUTH_SECRET'];
try {
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'requires-idempotency-secret',
correlationId: 'correlation-secret',
content: 'sensitive payload',
}),
).rejects.toThrow(/required for durable idempotency digests/);
} finally {
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = secret;
}
}, 30_000);
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const input = {
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-secret-conflict',
cursor: 'api_key=secret-one',
summary: 'bearer secret-one',
compactionEpoch: 1,
};
await coordinator.checkpoint(input);
await expect(
coordinator.checkpoint({
...input,
cursor: 'api_key=secret-two',
summary: 'bearer secret-two',
}),
).rejects.toThrow(/checkpoint identity conflict/);
const [persisted] = await handle.db
.select({
digest: interactionCheckpoints.contentDigest,
cursor: interactionCheckpoints.cursor,
})
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
expect(persisted?.cursor).not.toContain('secret-one');
expect(persisted?.digest).not.toBe(
createHash('sha256')
.update(JSON.stringify([input.cursor, input.summary]))
.digest('hex'),
);
await coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a\u0000b',
summary: 'c',
});
await expect(
coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a',
summary: 'b\u0000c',
}),
).rejects.toThrow(/checkpoint identity conflict/);
}, 30_000);
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const inbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-secret-conflict',
correlationId: 'correlation-inbox-secret',
content: 'api_key=secret-one',
};
const outbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-secret-conflict',
correlationId: 'correlation-outbox-secret',
channelId: 'cli',
kind: 'provider.send',
content: 'api_key=secret-one',
};
await coordinator.receive(inbox);
await coordinator.enqueueOutbox(outbox);
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
/idempotency conflict/,
);
await expect(
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'correlation-inbox',
content: 'original inbox',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'cli',
kind: 'provider.send',
content: 'original outbox',
});
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'forged-correlation',
content: 'original inbox',
}),
).rejects.toThrow(/idempotency conflict/);
await expect(
coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'forged-channel',
kind: 'provider.send',
content: 'original outbox',
}),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'live-effect',
correlationId: 'correlation-live',
content: 'must not duplicate',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-live',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
status: 'processing',
});
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
await expect(
service.recoverProviderSession(IDENTITY.sessionId, {
...input,
context: {
...input.context,
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
},
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
});
}, 30_000);
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'mismatch-effect',
correlationId: 'correlation-expected',
content: 'must remain pending',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-expected',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
await expect(
service.dispatchProviderOutbox(IDENTITY.sessionId, {
...input,
correlationId: 'correlation-forged',
context: { ...input.context, correlationId: 'correlation-forged' },
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
});
}, 30_000);
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const first = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'scoped-effect-one',
correlationId: 'correlation-one',
content: 'first result',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-one',
},
};
const second = {
...first,
idempotencyKey: 'scoped-effect-two',
correlationId: 'correlation-two',
content: 'second result',
context: { ...first.context, correlationId: 'correlation-two' },
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(first);
await service.queueProviderSend(second);
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
IDENTITY.providerId,
IDENTITY.runtimeSessionId,
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
first.context,
);
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
],
});
}, 30_000);
});
async function seedOwner(handle: DbHandle): Promise<void> {
await handle.db.execute(sql`
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
VALUES ('tess-owner', 'Tess Owner', '[email protected]', false, now(), now())
`);
}
@@ -0,0 +1,529 @@
import { createHash, createHmac } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
desc,
eq,
interactionCheckpoints,
interactionHandoffs,
interactionInbox,
interactionOutbox,
interactionSessions,
type Db,
} from '@mosaicstack/db';
import { seal, unseal } from '@mosaicstack/auth';
import { redactSensitiveContent } from '@mosaicstack/log';
import type {
DurableCheckpoint,
DurableCheckpointInput,
DurableEnqueueResult,
DurableHandoff,
DurableHandoffInput,
DurableInboxEntry,
DurableInboxInput,
DurableInboxStatus,
DurableOutboxEntry,
DurableOutboxInput,
DurableOutboxStatus,
DurableSessionIdentity,
DurableSessionSnapshot,
DurableSessionStore,
} from '@mosaicstack/agent';
import { DB } from '../database/database.module.js';
@Injectable()
export class DurableSessionRepository implements DurableSessionStore {
constructor(@Inject(DB) private readonly db: Db) {}
async create(identity: DurableSessionIdentity): Promise<void> {
await this.db
.insert(interactionSessions)
.values({
id: identity.sessionId,
agentName: identity.agentName,
tenantId: identity.tenantId,
ownerId: identity.ownerId,
providerId: identity.providerId,
runtimeSessionId: identity.runtimeSessionId,
})
.onConflictDoNothing();
const existing = await this.session(identity.sessionId);
if (!existing || !sameEnrollmentScope(existing, identity)) {
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
}
// A recovered/re-enrolled runtime can receive a new provider session ID;
// the conversation handle and owner scope remain immutable.
if (
existing.providerId !== identity.providerId ||
existing.runtimeSessionId !== identity.runtimeSessionId
) {
await this.db
.update(interactionSessions)
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
.where(eq(interactionSessions.id, identity.sessionId));
}
}
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
const identity = await this.session(sessionId);
if (!identity) return null;
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
this.db
.select()
.from(interactionInbox)
.where(eq(interactionInbox.sessionId, sessionId))
.orderBy(asc(interactionInbox.createdAt)),
this.db
.select()
.from(interactionOutbox)
.where(eq(interactionOutbox.sessionId, sessionId))
.orderBy(asc(interactionOutbox.createdAt)),
this.db
.select()
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.sessionId, sessionId))
.orderBy(
desc(interactionCheckpoints.compactionEpoch),
desc(interactionCheckpoints.createdAt),
)
.limit(1),
this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.sessionId, sessionId))
.orderBy(asc(interactionHandoffs.createdAt)),
]);
const checkpoint = checkpoints[0];
return {
identity,
inbox: inbox.map(toInbox),
outbox: outbox.map(toOutbox),
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
handoffs: handoffs.map(toHandoff),
};
}
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableInboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionInbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionInbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionInbox)
.where(
and(
eq(interactionInbox.sessionId, input.sessionId),
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
const entry = toInbox(existing[0]);
if (
!sameInbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionInbox)
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
)
.orderBy(asc(interactionInbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionInbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
.returning();
if (claimed[0]) return toInbox(claimed[0]);
}
return null;
}
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'processed', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async enqueueOutbox(
input: DurableOutboxInput,
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableOutboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionOutbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionOutbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionOutbox)
.where(
and(
eq(interactionOutbox.sessionId, input.sessionId),
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
const entry = toOutbox(existing[0]);
if (
!sameOutbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionOutbox)
.where(
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
)
.orderBy(asc(interactionOutbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
.returning();
if (claimed[0]) return toOutbox(claimed[0]);
}
return null;
}
async claimOutboxByKey(
sessionId: string,
idempotencyKey: string,
): Promise<DurableOutboxEntry | null> {
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'pending'),
),
)
.returning();
return claimed[0] ? toOutbox(claimed[0]) : null;
}
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'delivered', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async checkpoint(input: DurableCheckpointInput): Promise<void> {
// Compute identity before redaction. The persisted digest is keyed so a database
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
const checkpoint: DurableCheckpointInput = {
...input,
cursor: redactSensitiveContent(input.cursor).content,
summary: redactSensitiveContent(input.summary).content,
};
const inserted = await this.db
.insert(interactionCheckpoints)
.values({
...checkpoint,
contentDigest: digest,
cursor: seal(checkpoint.cursor),
summary: seal(checkpoint.summary),
})
.onConflictDoNothing()
.returning({ checkpointId: interactionCheckpoints.checkpointId });
if (inserted[0]) return;
const existing = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, input.sessionId),
eq(interactionCheckpoints.checkpointId, input.checkpointId),
),
)
.limit(1);
if (
!existing[0] ||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
) {
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
}
}
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
const checkpoints = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, sessionId),
eq(interactionCheckpoints.checkpointId, checkpointId),
),
)
.limit(1);
const checkpoint = checkpoints[0];
return checkpoint ? toCheckpoint(checkpoint) : null;
}
async handoff(input: DurableHandoffInput): Promise<void> {
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
if (!checkpoint) {
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const inserted = await this.db
.insert(interactionHandoffs)
.values({ ...input })
.onConflictDoNothing()
.returning({ handoffId: interactionHandoffs.handoffId });
if (inserted[0]) return;
const existing = await this.findHandoff(input.handoffId);
if (!existing || !sameHandoff(existing, input)) {
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
}
}
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
const handoffs = await this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.handoffId, handoffId))
.limit(1);
const handoff = handoffs[0];
return handoff ? toHandoff(handoff) : null;
}
async requeueInFlight(sessionId: string): Promise<void> {
// Inbox handlers are process-local work. A provider outbox claim may have
// reached an external target before a crash, so it is deliberately not
// replayed by generic recovery.
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
);
}
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
const sessions = await this.db
.select()
.from(interactionSessions)
.where(eq(interactionSessions.id, sessionId))
.limit(1);
const session = sessions[0];
return session
? {
agentName: session.agentName,
sessionId: session.id,
tenantId: session.tenantId,
ownerId: session.ownerId,
providerId: session.providerId,
runtimeSessionId: session.runtimeSessionId,
}
: null;
}
}
function contentDigest(content: string): string {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
}
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
}
function matchesContentDigest(stored: string, content: string): boolean {
return (
stored === contentDigest(content) ||
stored === createHash('sha256').update(content).digest('hex')
);
}
function matchesCheckpointDigest(stored: string, digest: string): boolean {
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
return stored === digest;
}
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
return (
left.agentName === right.agentName &&
left.sessionId === right.sessionId &&
left.tenantId === right.tenantId &&
left.ownerId === right.ownerId
);
}
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.content === right.content
);
}
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.channelId === right.channelId &&
left.kind === right.kind &&
left.content === right.content
);
}
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
return (
left.sessionId === right.sessionId &&
left.checkpointId === right.checkpointId &&
left.compactionEpoch === right.compactionEpoch
);
}
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
return (
left.sessionId === right.sessionId &&
left.handoffId === right.handoffId &&
left.destination === right.destination &&
left.correlationId === right.correlationId &&
left.checkpointId === right.checkpointId &&
left.status === right.status
);
}
function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
content: unseal(row.content),
status: row.status,
};
}
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
channelId: row.channelId,
kind: row.kind,
content: unseal(row.content),
status: row.status,
};
}
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
return {
sessionId: row.sessionId,
checkpointId: row.checkpointId,
cursor: unseal(row.cursor),
summary: unseal(row.summary),
compactionEpoch: row.compactionEpoch,
};
}
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
return {
sessionId: row.sessionId,
handoffId: row.handoffId,
destination: row.destination,
correlationId: row.correlationId,
checkpointId: row.checkpointId,
status: row.status,
};
}
@@ -0,0 +1,123 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import type { ProviderOutboxDto } from './durable-session.dto.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
* uses composition: raw state methods cannot be injected into channel, CLI, or
* MCP adapters without a server-derived actor/tenant/correlation context.
*/
@Injectable()
export class DurableSessionService {
private readonly coordinator: DurableSessionCoordinator;
constructor(
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
) {
this.coordinator = new DurableSessionCoordinator(repository);
}
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
async enroll(
identity: DurableSessionIdentity,
context: RuntimeProviderRequestContext,
): Promise<void> {
if (
identity.ownerId !== context.actorScope.userId ||
identity.tenantId !== context.actorScope.tenantId
) {
throw new ForbiddenException('Durable session enrollment scope mismatch');
}
await this.coordinator.create(identity);
}
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(input.sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.enqueueOutbox({
sessionId: input.sessionId,
idempotencyKey: input.idempotencyKey,
correlationId: input.correlationId,
channelId: input.context.channelId,
kind: 'provider.send',
content: input.content,
});
}
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
if (sessionId !== input.sessionId) {
throw new ForbiddenException('Durable outbox session mismatch');
}
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
const pendingEntry = snapshot.outbox.find(
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
);
if (!pendingEntry) return;
// Validate immutable routing before claiming. A caller with a mismatched
// correlation/channel must not strand a pending external side effect.
this.assertOutboxScope(pendingEntry, input);
await this.coordinator.dispatchOutboxEntry(
sessionId,
input.idempotencyKey,
async (entry): Promise<void> => {
this.assertOutboxScope(entry, input);
await this.runtimeProviders.sendMessage(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
input.context,
);
},
);
}
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
sessionId,
content: '',
idempotencyKey: 'read-only',
correlationId: context.correlationId,
context,
});
return snapshot;
}
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.recover(sessionId);
}
private assertOutboxScope(
entry: { kind: string; correlationId: string; channelId: string },
input: ProviderOutboxDto,
): void {
if (
entry.kind !== 'provider.send' ||
entry.correlationId !== input.correlationId ||
entry.channelId !== input.context.channelId
) {
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
}
}
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
if (
input.context.actorScope.userId !== ownerId ||
input.context.actorScope.tenantId !== tenantId ||
input.context.correlationId !== input.correlationId
) {
throw new ForbiddenException('Durable session scope or correlation mismatch');
}
}
}
@@ -0,0 +1,176 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Global, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentModule } from './agent.module.js';
import { AUTH } from '../auth/auth.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { DB } from '../database/database.module.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
} from './runtime-provider-registry.service.js';
import { DurableSessionService } from './durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
import { RoutingService } from './routing.service.js';
import { RoutingEngineService } from './routing/routing-engine.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' };
@Module({})
class EmptyAgentDependencyModule {}
@Global()
@Module({
providers: [
{
provide: AUTH,
useValue: {
api: {
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
headers.get('cookie') === 'session=trusted'
? { user: authenticatedUser, session: { id: 'session-1' } }
: null,
),
},
},
},
AuthGuard,
{ provide: BRAIN, useValue: {} },
{ provide: DB, useValue: {} },
],
exports: [AUTH, AuthGuard, BRAIN, DB],
})
class AuthenticatedRequestModule {}
/**
* This is deliberately an HTTP test rather than a controller unit test: it
* exercises AgentModule's actual provider factory, Nest DI, and AuthGuard.
*/
describe('Hermes runtime provider reachability', (): void => {
let app: NestFastifyApplication | undefined;
beforeAll(async (): Promise<void> => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const moduleRef = await Test.createTestingModule({
imports: [AuthenticatedRequestModule, AgentModule],
})
.overrideModule(CoordModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(McpClientModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(SkillsModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(GCModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(LogModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(CommandsModule)
.useModule(EmptyAgentDependencyModule)
.overrideProvider(RuntimeProviderAuditService)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
.useValue({ consume: vi.fn().mockResolvedValue(false) })
.overrideProvider(DurableSessionService)
.useValue({})
.overrideProvider(DurableSessionRepository)
.useValue({})
.overrideProvider(AgentService)
.useValue({})
.overrideProvider(ProviderService)
.useValue({})
.overrideProvider(ProviderCredentialsService)
.useValue({})
.overrideProvider(RoutingService)
.useValue({})
.overrideProvider(RoutingEngineService)
.useValue({})
.overrideProvider(SkillLoaderService)
.useValue({})
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async (): Promise<void> => {
await app?.close();
});
it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise<void> => {
if (!app) throw new Error('Nest application did not initialize');
const attachDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/attach',
headers: { 'x-correlation-id': 'correlation-1' },
payload: { mode: 'read' },
});
expect(attachDenied.statusCode).toBe(401);
const sendDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/send',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
payload: {},
});
expect(sendDenied.statusCode).toBe(403);
expect(sendDenied.json()).toMatchObject({
message: 'Content and idempotency key are required',
});
const stopDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/stop',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
payload: {},
});
expect(stopDenied.statusCode).toBe(403);
expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' });
});
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
if (!app) throw new Error('Nest application did not initialize');
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider);
const denied = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { 'x-correlation-id': 'correlation-1' },
});
expect(denied.statusCode).toBe(401);
const response = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
const scope = {
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
};
describe('GatewayHermesRuntimeTransport', () => {
it('preserves a configured path prefix and authenticates the concrete runtime request', async () => {
const fetchFn = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 }));
const transport = new GatewayHermesRuntimeTransport(
'https://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']);
expect(fetchFn).toHaveBeenCalledWith(
new URL('https://runtime.example.test/hermes/capabilities'),
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer test-service-token',
'x-mosaic-channel-id': 'cli',
}),
}),
);
});
it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => {
const fetchFn = vi.fn();
const transport = new GatewayHermesRuntimeTransport(
'http://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS');
expect(fetchFn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,121 @@
import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent';
import type {
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeMessage,
RuntimeScope,
RuntimeStreamEvent,
} from '@mosaicstack/types';
/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */
export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport {
constructor(
private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(),
private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(),
private readonly fetchFn: typeof fetch = fetch,
) {}
async capabilities(scope: RuntimeScope): Promise<string[]> {
return this.request<string[]>('/capabilities', scope);
}
async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> {
return this.request<{ status: string; detail?: string }>('/health', scope);
}
async sessions(scope: RuntimeScope): Promise<HermesLegacySession[]> {
return this.request<HermesLegacySession[]>('/sessions', scope);
}
async *stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
const params = new URLSearchParams(cursor ? { cursor } : {});
const events = await this.request<RuntimeStreamEvent[]>(
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
scope,
);
yield* events;
}
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
method: 'POST',
body: message,
});
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
return this.request<RuntimeAttachHandle>(
`/sessions/${encodeURIComponent(sessionId)}/attach`,
scope,
{
method: 'POST',
body: { mode },
},
);
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
method: 'DELETE',
});
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
method: 'POST',
body: { approvalRef },
});
}
private async request<T>(
path: string,
scope: RuntimeScope,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
if (!this.baseUrl || !this.serviceToken) {
throw new Error(
'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport',
);
}
const endpoint = new URL(this.baseUrl);
if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) {
throw new Error('Hermes runtime transport requires HTTPS outside loopback');
}
const response = await this.fetchFn(
new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`),
{
method: init.method ?? 'GET',
headers: {
accept: 'application/json',
authorization: `Bearer ${this.serviceToken}`,
'x-mosaic-actor-id': scope.actorId,
'x-mosaic-tenant-id': scope.tenantId,
'x-mosaic-channel-id': scope.channelId,
'x-correlation-id': scope.correlationId,
...(init.body ? { 'content-type': 'application/json' } : {}),
},
...(init.body ? { body: JSON.stringify(init.body) } : {}),
},
);
if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`);
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
}
function isLoopbackHttp(endpoint: URL): boolean {
return (
endpoint.protocol === 'http:' &&
(endpoint.hostname === 'localhost' ||
endpoint.hostname === '127.0.0.1' ||
endpoint.hostname === '::1')
);
}
@@ -0,0 +1,263 @@
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
import { firstValueFrom } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
RuntimeApprovalDeniedError,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import { InteractionController } from './interaction.controller.js';
describe('InteractionController', (): void => {
afterEach(() => vi.restoreAllMocks());
it('maps a denied runtime approval to Fastify HTTP 403', () => {
const send = vi.fn();
const status = vi.fn().mockReturnValue({ send });
const response = { status };
const host = { switchToHttp: () => ({ getResponse: () => response }) };
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
expect(status).toHaveBeenCalledWith(403);
expect(send).toHaveBeenCalledWith({
statusCode: 403,
message: 'Runtime termination approval denied',
});
});
it('honors a differently named configured instance without a code change', async () => {
const prior = process.env['MOSAIC_AGENT_NAME'];
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
const controller = new InteractionController(runtime as never, {} as never);
await expect(
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).resolves.toEqual([]);
await expect(
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).rejects.toThrow('Interaction agent is not configured');
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prior;
});
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const registry = createGatewayRuntimeProviderRegistry();
const runtime = new RuntimeProviderService(
registry,
{ record: vi.fn().mockResolvedValue(undefined) },
{ consume: vi.fn().mockResolvedValue(false) },
);
const controller = new InteractionController(runtime, {} as never);
await expect(
controller.transitionalCapabilities(
'Nova',
'runtime.hermes',
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
it('rejects a request without the non-simple correlation header', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
'X-Correlation-Id is required',
);
});
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.enroll(
'Nova',
'conversation-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
expect(durable.enroll).toHaveBeenCalledWith(
{
agentName: 'Nova',
sessionId: 'conversation-1',
tenantId: 'team',
ownerId: 'owner',
providerId: 'fleet',
runtimeSessionId: 'runtime-1',
},
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('rejects an invalid attach mode before invoking a provider', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
await expect(
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
).rejects.toThrow('Interaction attach mode is invalid');
});
it('resumes a durable session by attaching and streaming its runtime events', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtimeEvent = {
type: 'message.delta' as const,
sessionId: 'runtime-1',
cursor: 'cursor-1',
occurredAt: '2026-07-13T00:00:00.000Z',
content: 'resumed',
};
const runtime = {
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
streamSession: vi.fn(async function* () {
yield runtimeEvent;
}),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
await expect(
firstValueFrom(
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
),
).resolves.toEqual({ data: runtimeEvent });
expect(runtime.attach).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'read',
expect.objectContaining({ correlationId: 'corr-1' }),
);
expect(runtime.streamSession).toHaveBeenCalledWith(
'fleet',
'runtime-1',
undefined,
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
resolveSnapshot = resolve;
});
const runtime = { streamSession: vi.fn() };
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
const controller = new InteractionController(runtime as never, durable as never);
const subscription = controller
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
.subscribe();
subscription.unsubscribe();
resolveSnapshot({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(runtime.streamSession).not.toHaveBeenCalled();
});
it.each([
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
[
'session-agent mismatch',
{
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
},
],
])('denies a CLI stop for %s', async (_reason, durable) => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toBeDefined();
expect(runtime.terminate).not.toHaveBeenCalled();
});
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toThrow('Runtime termination approval denied');
});
it('uses the durable session identity and runtime registry for an approved stop', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
);
expect(runtime.terminate).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'approval-1',
expect.objectContaining({
correlationId: 'corr-1',
actorScope: { userId: 'owner', tenantId: 'owner' },
}),
);
});
});
@@ -0,0 +1,293 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Sse,
Inject,
Param,
Post,
Query,
UseGuards,
UseFilters,
} from '@nestjs/common';
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
import { Observable } from 'rxjs';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { DurableSessionService } from './durable-session.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Authenticated HTTP boundary for operator interaction clients. Identity is
* selected from deployment configuration, never a client-side command name.
*/
@Controller('api/interaction/:agentName')
@UseGuards(AuthGuard)
@UseFilters(RuntimeApprovalDeniedFilter)
export class InteractionController {
constructor(
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
) {}
@Get('sessions')
async sessions(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.listSessions(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('transitional-capabilities')
async transitionalCapabilities(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.transitionalCapabilityMatrix(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('tree')
async tree(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.getSessionTree(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
/**
* Bind an existing, authorized runtime session to the stable conversation ID.
* This is the lifecycle boundary where both runtime identifiers are known.
*/
@Post('sessions/:sessionId/enroll')
async enroll(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const providerId = this.requiredProvider(body.providerId ?? '');
const runtimeSessionId = body.runtimeSessionId?.trim();
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
const context = this.context(user, correlationId);
const sessions = await this.runtime.listSessions(providerId, context);
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
throw new ForbiddenException('Runtime session is not visible to this actor');
}
await this.durable.enroll(
{
agentName,
sessionId,
tenantId: context.actorScope.tenantId,
ownerId: context.actorScope.userId,
providerId,
runtimeSessionId,
},
context,
);
return { status: 'enrolled', sessionId };
}
@Post('sessions/:sessionId/attach')
async attach(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { mode?: RuntimeAttachMode } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const mode = body.mode ?? 'read';
if (mode !== 'read' && mode !== 'control') {
throw new ForbiddenException('Interaction attach mode is invalid');
}
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
return this.runtime.attach(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
mode,
context,
);
}
@Sse('sessions/:sessionId/stream')
stream(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Query('cursor') cursor: string | undefined,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Observable<{ data: RuntimeStreamEvent }> {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
return new Observable((subscriber) => {
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
let cancelled = false;
void (async (): Promise<void> => {
try {
const snapshot = await this.durable.getSnapshot(sessionId, context);
if (cancelled || subscriber.closed) return;
this.assertSessionAgent(snapshot.identity.agentName, agentName);
iterator = this.runtime
.streamSession(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
cursor?.trim() || undefined,
context,
)
[Symbol.asyncIterator]();
if (cancelled || subscriber.closed) {
await iterator.return?.();
return;
}
while (!cancelled && !subscriber.closed) {
const next = await iterator.next();
if (next.done || cancelled || subscriber.closed) break;
subscriber.next({ data: next.value });
}
if (!subscriber.closed) subscriber.complete();
} catch (error: unknown) {
if (!subscriber.closed) subscriber.error(error);
}
})();
return (): void => {
cancelled = true;
void iterator?.return?.();
};
});
}
@Post('sessions/:sessionId/send')
async send(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { content?: string; idempotencyKey?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
throw new ForbiddenException('Content and idempotency key are required');
}
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
const input = {
sessionId,
content: body.content,
idempotencyKey: body.idempotencyKey,
correlationId: context.correlationId,
context,
};
await this.durable.queueProviderSend(input);
await this.durable.dispatchProviderOutbox(sessionId, input);
return { status: 'queued', sessionId };
}
@Post('sessions/:sessionId/stop')
async stop(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { approvalRef?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.approvalRef?.trim())
throw new ForbiddenException('Exact-action approval is required');
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.runtime.terminate(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
body.approvalRef,
context,
);
return { status: 'stopped', sessionId };
}
@Post('sessions/:sessionId/recover')
async recover(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.durable.recoverProviderSession(sessionId, {
sessionId,
content: '',
idempotencyKey: `recovery:${context.correlationId}`,
correlationId: context.correlationId,
context,
});
return { status: 'recovered', sessionId };
}
private context(
user: AuthenticatedUserLike,
correlationId?: string,
): RuntimeProviderRequestContext {
const requestCorrelationId = correlationId?.trim();
// This non-simple request header is mandatory for mutations. Browser
// cross-origin requests cannot set it without a CORS preflight, and the
// gateway's allowlist rejects untrusted origins before the handler runs.
if (!requestCorrelationId) {
throw new ForbiddenException('X-Correlation-Id is required');
}
return {
actorScope: scopeFromUser(user),
channelId: 'cli',
correlationId: requestCorrelationId,
};
}
private assertConfiguredAgent(agentName: string): void {
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!configured || configured !== agentName) {
throw new ForbiddenException('Interaction agent is not configured for this request');
}
}
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
if (sessionAgentName !== agentName)
throw new ForbiddenException('Interaction session identity mismatch');
}
private requiredProvider(providerId: string): string {
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
return providerId;
}
}
+23 -2
View File
@@ -107,8 +107,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
*/
private startHealthCheckScheduler(): void {
const intervalSecs =
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS;
const intervalSecs = this.effectiveHealthCheckIntervalSecs();
const intervalMs = intervalSecs * 1000;
// Run an initial check immediately (non-blocking)
@@ -176,6 +175,28 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
});
}
/**
* Returns the effective provider operational policy without credentials,
* endpoints, request content, or provider error details.
*/
getEffectivePolicyStatus(): {
healthCheckIntervalSecs: number;
configuredProviders: string[];
availableModelCount: number;
} {
return {
healthCheckIntervalSecs: this.effectiveHealthCheckIntervalSecs(),
configuredProviders: this.adapters.map((adapter) => adapter.name),
availableModelCount: this.registry?.getAvailable().length ?? 0,
};
}
private effectiveHealthCheckIntervalSecs(): number {
return (
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS
);
}
// ---------------------------------------------------------------------------
// Adapter-pattern API
// ---------------------------------------------------------------------------
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { ProvidersController } from './providers.controller.js';
describe('ProvidersController operational status', (): void => {
it('reports provider latency and effective policy without exposing provider error details', (): void => {
const providerService = {
getProvidersHealth: vi.fn(() => [
{
name: 'fleet',
status: 'down',
latencyMs: 42,
lastChecked: '2026-07-12T00:00:00.000Z',
modelCount: 0,
error: 'credential-canary=secret-value',
},
]),
getEffectivePolicyStatus: vi.fn(() => ({
healthCheckIntervalSecs: 60,
configuredProviders: ['fleet'],
availableModelCount: 0,
})),
};
const controller = new ProvidersController(providerService as never, {} as never, {} as never);
const status = controller.status();
expect(status).toEqual({
providers: [
{
name: 'fleet',
status: 'down',
latencyMs: 42,
lastChecked: '2026-07-12T00:00:00.000Z',
modelCount: 0,
errorCode: 'provider_unavailable',
},
],
effectivePolicy: {
healthCheckIntervalSecs: 60,
configuredProviders: ['fleet'],
availableModelCount: 0,
},
});
expect(JSON.stringify(status)).not.toContain('secret-value');
});
});
+21 -1
View File
@@ -33,7 +33,20 @@ export class ProvidersController {
@Get('health')
health() {
return { providers: this.providerService.getProvidersHealth() };
return { providers: this.safeProviderHealth() };
}
/**
* Safe operational status for troubleshooting and readiness checks. Provider
* errors are reduced to a stable code so credentials and remote responses
* cannot leak through this endpoint.
*/
@Get('status')
status() {
return {
providers: this.safeProviderHealth(),
effectivePolicy: this.providerService.getEffectivePolicyStatus(),
};
}
@Post('test')
@@ -51,6 +64,13 @@ export class ProvidersController {
return this.routingService.rank(criteria);
}
private safeProviderHealth() {
return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({
...provider,
...(error ? { errorCode: 'provider_unavailable' } : {}),
}));
}
// ── Credential CRUD ──────────────────────────────────────────────────────
/**
@@ -0,0 +1,13 @@
import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common';
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */
@Catch(RuntimeApprovalDeniedError)
export class RuntimeApprovalDeniedFilter implements ExceptionFilter {
catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<{
status(code: number): { send(body: { statusCode: number; message: string }): void };
}>();
response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' });
}
}
@@ -0,0 +1,500 @@
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import {
createRuntimeAuditLogEntry,
type LogService,
type RuntimeAuditErrorCode,
} from '@mosaicstack/log';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
TransitionalCapabilityInventoryEntry,
TransitionalCapabilityInventoryProvider,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { LOG_SERVICE } from '../log/log.tokens.js';
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
export type RuntimeProviderOperation =
| RuntimeCapability
| 'runtime.capabilities'
| 'runtime.health'
| 'runtime.transitional-capabilities';
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
export interface RuntimeProviderRequestContext {
actorScope: ActorTenantScope;
channelId: string;
correlationId: string;
}
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
export interface RuntimeAuditEvent {
providerId: string;
operation: RuntimeProviderOperation;
outcome: RuntimeProviderAuditOutcome;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
resourceId?: string;
durationMs?: number;
errorCode?: RuntimeAuditErrorCode;
}
export interface RuntimeAuditSink {
record(event: RuntimeAuditEvent): Promise<void>;
}
/** Exact action shape that a durable approval implementation must consume once. */
export interface RuntimeTerminationAction {
providerId: string;
sessionId: string;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
agentName: string;
}
export interface RuntimeApprovalVerifier {
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
}
function isTransitionalInventoryProvider(
provider: AgentRuntimeProvider,
): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider {
return (
typeof (provider as Partial<TransitionalCapabilityInventoryProvider>)
.transitionalCapabilityMatrix === 'function'
);
}
function configuredAgentName(): string {
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!agentName) throw new RuntimeApprovalDeniedError();
return agentName;
}
export class RuntimeApprovalDeniedError extends Error {
constructor() {
super('Runtime termination approval denied');
}
}
/**
* The default denies all runtime termination until a durable, exact-action
* approval implementation is configured. This is safer than a permissive stub.
*/
@Injectable()
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
return false;
}
}
/**
* Temporary metadata-only audit sink. M1 observability can replace this token
* with a durable audit writer without changing provider call sites.
*/
@Injectable()
export class RuntimeProviderAuditService implements RuntimeAuditSink {
private readonly logger = new Logger(RuntimeProviderAuditService.name);
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
async record(event: RuntimeAuditEvent): Promise<void> {
const entry = createRuntimeAuditLogEntry(event);
await this.logService.logs.ingest(entry);
this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata }));
}
}
@Injectable()
export class RuntimeProviderService {
private readonly logger = new Logger(RuntimeProviderService.name);
constructor(
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
private readonly registry: AgentRuntimeProviderRegistry,
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly audit: RuntimeAuditSink,
@Inject(RUNTIME_APPROVAL_VERIFIER)
private readonly approvals: RuntimeApprovalVerifier,
) {}
async capabilities(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeCapabilitySet> {
return this.execute(
providerId,
'runtime.capabilities',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
provider.capabilities(scope),
);
}
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
return this.execute(
providerId,
'runtime.health',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
provider.health(scope),
);
}
async transitionalCapabilityMatrix(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<TransitionalCapabilityInventoryEntry[]> {
return this.execute(
providerId,
'runtime.transitional-capabilities',
undefined,
undefined,
context,
async (provider: AgentRuntimeProvider, scope: RuntimeScope) => {
if (!isTransitionalInventoryProvider(provider)) {
throw new NotFoundException('Runtime provider has no transitional capability inventory');
}
return provider.transitionalCapabilityMatrix(scope);
},
);
}
async listSessions(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSession[]> {
return this.execute(
providerId,
'session.list',
'session.list',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
provider.listSessions(scope),
);
}
async getSessionTree(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSessionTree[]> {
return this.execute(
providerId,
'session.tree',
'session.tree',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
provider.getSessionTree(scope),
);
}
streamSession(
providerId: string,
sessionId: string,
cursor: string | undefined,
context: RuntimeProviderRequestContext,
): AsyncIterable<RuntimeStreamEvent> {
return this.stream(
providerId,
'session.stream',
'session.stream',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
provider.streamSession(sessionId, cursor, scope),
);
}
async sendMessage(
providerId: string,
sessionId: string,
message: RuntimeMessage,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.send',
'session.send',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.sendMessage(sessionId, message, scope),
);
}
async attach(
providerId: string,
sessionId: string,
mode: RuntimeAttachMode,
context: RuntimeProviderRequestContext,
): Promise<RuntimeAttachHandle> {
return this.execute(
providerId,
'session.attach',
'session.attach',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
provider.attach(sessionId, mode, scope),
);
}
async detach(
providerId: string,
attachmentId: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.attach',
'session.attach',
attachmentId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.detach(attachmentId, scope),
);
}
async terminate(
providerId: string,
sessionId: string,
approvalRef: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.terminate',
'session.terminate',
sessionId,
context,
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
const approved = await this.approvals.consume(approvalRef, {
providerId,
sessionId,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
agentName: configuredAgentName(),
});
if (!approved) {
throw new RuntimeApprovalDeniedError();
}
await provider.terminate(sessionId, approvalRef, scope);
},
);
}
private async execute<T>(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability | undefined,
resourceId: string | undefined,
context: RuntimeProviderRequestContext,
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
): Promise<T> {
const scope = this.deriveScope(context);
const startedAt = Date.now();
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
if (requiredCapability) {
await this.assertCapability(provider, requiredCapability, scope);
}
invocationStarted = true;
const result = await invoke(provider, scope);
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
return result;
} catch (error: unknown) {
const durationMs = Date.now() - startedAt;
if (invocationStarted && !this.isAuthorizationDenied(error)) {
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
} else {
await this.record(
providerId,
operation,
'denied',
scope,
resourceId,
durationMs,
'policy_denied',
);
}
throw error;
}
}
private async *stream(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability,
resourceId: string,
context: RuntimeProviderRequestContext,
invoke: (
provider: AgentRuntimeProvider,
scope: RuntimeScope,
) => AsyncIterable<RuntimeStreamEvent>,
): AsyncIterable<RuntimeStreamEvent> {
const scope = this.deriveScope(context);
const startedAt = Date.now();
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
await this.assertCapability(provider, requiredCapability, scope);
invocationStarted = true;
for await (const event of invoke(provider, scope)) {
yield event;
}
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
} catch (error: unknown) {
const durationMs = Date.now() - startedAt;
if (invocationStarted) {
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
} else {
await this.record(
providerId,
operation,
'denied',
scope,
resourceId,
durationMs,
'policy_denied',
);
}
throw error;
}
}
private isAuthorizationDenied(error: unknown): boolean {
return (
error instanceof RuntimeApprovalDeniedError ||
error instanceof ForbiddenException ||
(typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'forbidden')
);
}
private provider(providerId: string): AgentRuntimeProvider {
try {
return this.registry.require(providerId);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
throw new NotFoundException(message);
}
}
private async assertCapability(
provider: AgentRuntimeProvider,
requiredCapability: RuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
const capabilities = await provider.capabilities(scope);
if (!capabilities.supported.includes(requiredCapability)) {
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
}
}
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
const actorId = context.actorScope.userId.trim();
const tenantId = context.actorScope.tenantId.trim();
const channelId = context.channelId.trim();
const correlationId = context.correlationId.trim();
if (!actorId || !tenantId || !channelId || !correlationId) {
throw new ForbiddenException(
'Authenticated runtime actor scope and correlation are required',
);
}
return Object.freeze({ actorId, tenantId, channelId, correlationId });
}
private async recordFailure(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs: number,
): Promise<void> {
try {
await this.record(
providerId,
operation,
'failed',
scope,
resourceId,
durationMs,
'provider_error',
);
} catch {
this.logger.error(
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async recordCompletion(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs: number,
): Promise<void> {
try {
await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs);
} catch {
this.logger.error(
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async record(
providerId: string,
operation: RuntimeProviderOperation,
outcome: RuntimeProviderAuditOutcome,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs?: number,
errorCode?: RuntimeAuditErrorCode,
): Promise<void> {
await this.audit.record({
providerId,
operation,
outcome,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
...(resourceId ? { resourceId } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
...(errorCode ? { errorCode } : {}),
});
}
}
+10 -7
View File
@@ -10,6 +10,8 @@ import {
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { AgentService } from './agent.service.js';
@Controller('api/sessions')
@@ -18,23 +20,24 @@ export class SessionsController {
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Get()
list() {
const sessions = this.agentService.listSessions();
list(@CurrentUser() user: AuthenticatedUserLike) {
const sessions = this.agentService.listSessions(scopeFromUser(user));
return { sessions, total: sessions.length };
}
@Get(':id')
findOne(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
if (!info) throw new NotFoundException('Session not found');
return info;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async destroy(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const scope = scopeFromUser(user);
const info = this.agentService.getSessionInfo(id, scope);
if (!info) throw new NotFoundException('Session not found');
await this.agentService.destroySession(id);
await this.agentService.destroySession(id, scope);
}
}
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { createMemoryTools } from './memory-tools.js';
describe('createMemoryTools operator retrieval binding', () => {
const memory = {
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
};
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
const plugin = {
search: vi.fn(async () => []),
capture: vi.fn(async () => ({ id: 'insight-1' })),
};
const tools = createMemoryTools(memory as never, null, 'owner-a', {
plugin: plugin as never,
scope,
});
await tools
.find((tool) => tool.name === 'memory_search')!
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
await tools
.find((tool) => tool.name === 'memory_save_insight')!
.execute(
'call-2',
{ content: 'secret', category: 'decision' },
undefined,
undefined,
{} as never,
);
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
expect(plugin.capture).toHaveBeenCalledWith(scope, {
content: 'secret',
source: 'agent',
category: 'decision',
});
});
});
+29 -3
View File
@@ -1,7 +1,11 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { Memory } from '@mosaicstack/memory';
import type { EmbeddingProvider } from '@mosaicstack/memory';
import type {
EmbeddingProvider,
Memory,
OperatorMemoryPlugin,
OperatorMemoryScope,
} from '@mosaicstack/memory';
/**
* Create memory tools bound to the session's authenticated userId.
@@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaicstack/memory';
export function createMemoryTools(
memory: Memory,
embeddingProvider: EmbeddingProvider | null,
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
sessionUserId: string | undefined,
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
): ToolDefinition[] {
/** Return an error result when no session user is bound. */
function noUserError() {
@@ -46,6 +52,14 @@ export function createMemoryTools(
limit?: number;
};
if (operatorMemory) {
const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5);
return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
details: undefined,
};
}
if (!embeddingProvider) {
return {
content: [
@@ -158,6 +172,18 @@ export function createMemoryTools(
};
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
if (operatorMemory) {
const insight = await operatorMemory.plugin.capture(operatorMemory.scope, {
content,
source: 'agent',
category: category ?? 'learning',
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
details: undefined,
};
}
let embedding: number[] | null = null;
if (embeddingProvider) {
embedding = await embeddingProvider.embed(content);
+24
View File
@@ -0,0 +1,24 @@
export interface AuthenticatedUserLike {
id: string;
tenantId?: string | null;
teamId?: string | null;
organizationId?: string | null;
orgId?: string | null;
}
export interface ActorTenantScope {
userId: string;
tenantId: string;
}
/**
* Build the immutable server-derived scope used for Tess session operations.
* Current Mosaic auth is user-scoped; future org/team claims can populate one
* of the tenant fields without allowing clients to choose another tenant.
*/
export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope {
return {
userId: user.id,
tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id,
};
}
@@ -12,7 +12,8 @@ describe('Chat controller source hardening', () => {
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect(source).toContain('@UseGuards(AuthGuard)');
expect(source).toContain('@CurrentUser() user: { id: string }');
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
expect(source).toContain('const scope = scopeFromUser(user);');
});
});
+31 -14
View File
@@ -3,8 +3,10 @@ import {
Post,
Body,
Logger,
ForbiddenException,
HttpException,
HttpStatus,
NotFoundException,
Inject,
UseGuards,
} from '@nestjs/common';
@@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { v4 as uuid } from 'uuid';
import { ChatRequestDto } from './chat.dto.js';
@@ -32,16 +35,23 @@ export class ChatController {
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async chat(
@Body() body: ChatRequestDto,
@CurrentUser() user: { id: string },
@CurrentUser() user: AuthenticatedUserLike,
): Promise<ChatResponse> {
const conversationId = body.conversationId ?? uuid();
const scope = scopeFromUser(user);
try {
let agentSession = this.agentService.getSession(conversationId);
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId);
agentSession = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
});
}
} catch (err) {
if (err instanceof ForbiddenException) {
throw new NotFoundException('Session not found');
}
this.logger.error(
`Session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
@@ -60,20 +70,27 @@ export class ChatController {
reject(new Error('Agent response timed out'));
}, 120_000);
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
});
const cleanup = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
},
scope,
);
});
try {
await this.agentService.prompt(conversationId, body.content);
await this.agentService.prompt(conversationId, body.content, scope);
await done;
} catch (err) {
if (err instanceof HttpException) throw err;
@@ -1,3 +1,4 @@
import { timingSafeEqual } from 'node:crypto';
import type { IncomingHttpHeaders } from 'node:http';
import { fromNodeHeaders } from 'better-auth/node';
@@ -12,6 +13,19 @@ export interface SessionAuth {
};
}
export function validateDiscordServiceToken(
candidate: unknown,
expected: string | undefined,
): boolean {
if (typeof candidate !== 'string' || !expected) return false;
const candidateBuffer = Buffer.from(candidate);
const expectedBuffer = Buffer.from(expected);
return (
candidateBuffer.length === expectedBuffer.length &&
timingSafeEqual(candidateBuffer, expectedBuffer)
);
}
export async function validateSocketSession(
headers: IncomingHttpHeaders,
auth: SessionAuth,
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { ChatGateway } from './chat.gateway.js';
const payload: SlashCommandPayload = {
command: 'gc',
conversationId: 'conversation-1',
approvalId: 'approval-1',
};
function buildGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
commandExecutor as never,
{} as never,
);
}
describe('ChatGateway command approval ingress', () => {
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
createApproval: vi.fn(),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, payload);
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
userId: 'admin-1',
tenantId: 'admin-1',
});
expect(client.emit).toHaveBeenCalledWith(
'command:result',
expect.objectContaining({ success: true }),
);
});
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn(),
createApproval: vi.fn().mockResolvedValue({
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
}),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandApproval(client as never, {
command: 'gc',
conversationId: 'conversation-1',
});
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
{ command: 'gc', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith('command:approval', {
command: 'gc',
conversationId: 'conversation-1',
success: true,
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
});
});
});
@@ -0,0 +1,170 @@
import { describe, expect, it, vi } from 'vitest';
import { ChatGateway } from './chat.gateway.js';
const CONVERSATION_ID = 'conversation-1';
const CANARY = 'sk_canary12345678';
type GatewayInternals = {
clientSessions: Map<string, unknown>;
relayEvent(client: unknown, conversationId: string, event: unknown): void;
};
function buildGateway() {
const brain = {
conversations: {
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
return { gateway: gateway as unknown as GatewayInternals, brain };
}
describe('ChatGateway redaction boundary', (): void => {
it('redacts a secret split across assistant deltas before egress and persistence', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
const session = {
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
};
gateway.clientSessions.set(client.id, session);
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' },
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary');
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(session.assistantText).toBe(`${CANARY} `);
expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY);
});
it('retains a split secret label until its value can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'token ' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123');
});
it('holds a streamed private key until it can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET]',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary');
});
it('drops an oversized unterminated stream fragment rather than retaining it', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_STREAM_OVERFLOW]',
});
});
it('persists only redacted assistant content with classifications', (): void => {
const { gateway, brain } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.clientSessions.set(client.id, {
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: CANARY,
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
});
gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' });
expect(brain.conversations.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: '[REDACTED_SECRET]',
metadata: expect.objectContaining({ classifications: ['secret'] }),
}),
'user-1',
);
expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY);
});
});
+637 -41
View File
@@ -1,4 +1,5 @@
import { Inject, Logger } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { Inject, Logger, Optional } from '@nestjs/common';
import {
WebSocketGateway,
WebSocketServer,
@@ -11,24 +12,47 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import {
verifyDiscordIngressEnvelope,
parseDiscordInteractionBindings,
resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding,
type DiscordIngressEnvelope,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import type { Auth } from '@mosaicstack/auth';
import type { Brain } from '@mosaicstack/brain';
import { redactSensitiveContent } from '@mosaicstack/log';
import type {
SetThinkingPayload,
SlashCommandApprovalResultPayload,
SlashCommandPayload,
SystemReloadPayload,
RoutingDecisionInfo,
AbortPayload,
} from '@mosaicstack/types';
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
import {
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderService,
type RuntimeAuditSink,
} from '../agent/runtime-provider-registry.service.js';
import { DurableSessionService } from '../agent/durable-session.service.js';
import { AUTH } from '../auth/auth.tokens.js';
import {
scopeFromUser,
type ActorTenantScope,
type AuthenticatedUserLike,
} from '../auth/session-scope.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { CommandRegistryService } from '../commands/command-registry.service.js';
import { CommandExecutorService } from '../commands/command-executor.service.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
import { v4 as uuid } from 'uuid';
import { ChatSocketMessageDto } from './chat.dto.js';
import { validateSocketSession } from './chat.gateway-auth.js';
import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js';
import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
/** Per-client state tracking streaming accumulation for persistence. */
interface ClientSession {
@@ -40,6 +64,8 @@ interface ClientSession {
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
/** Tool calls in-flight (started but not ended yet). */
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
/** Server-derived owner/tenant scope for this socket's conversation attachment. */
scope: ActorTenantScope;
/** Last routing decision made for this session (M4-008) */
lastRoutingDecision?: RoutingDecisionInfo;
}
@@ -49,6 +75,38 @@ interface ClientSession {
* Keyed by conversationId, value is the model name to use.
*/
const modelOverrides = new Map<string, string>();
const MAX_REDACTION_BUFFER_LENGTH = 8_192;
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
if (typeof value !== 'object' || value === null) return false;
const envelope = value as { payload?: unknown; signature?: unknown };
if (
typeof envelope.signature !== 'string' ||
typeof envelope.payload !== 'object' ||
envelope.payload === null
) {
return false;
}
const payload = envelope.payload as Record<string, unknown>;
return [
payload['correlationId'],
payload['messageId'],
payload['guildId'],
payload['channelId'],
payload['userId'],
payload['conversationId'],
payload['content'],
].every((field: unknown): boolean => typeof field === 'string');
}
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
if (typeof value !== 'object' || value === null) return false;
const payload = value as { content?: unknown; conversationId?: unknown };
return (
typeof payload.content === 'string' &&
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
);
}
@WebSocketGateway({
cors: {
@@ -62,6 +120,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
private readonly logger = new Logger(ChatGateway.name);
private readonly clientSessions = new Map<string, ClientSession>();
/** Raw stream fragments are kept in memory only until they are safe to redact and emit. */
private readonly textEgressBuffers = new Map<string, string>();
private readonly thinkingEgressBuffers = new Map<string, string>();
private readonly overflowedEgress = new Set<string>();
private readonly discordReplayProtector = new DiscordReplayProtector();
constructor(
@Inject(AgentService) private readonly agentService: AgentService,
@@ -70,6 +133,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
@Optional()
@Inject(CommandAuthorizationService)
private readonly commandAuthorization: CommandAuthorizationService | null = null,
@Optional()
@Inject(RuntimeProviderService)
private readonly runtimeRegistry: RuntimeProviderService | null = null,
@Optional()
@Inject(DurableSessionService)
private readonly durableSessions: DurableSessionService | null = null,
@Optional()
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly runtimeAudit: RuntimeAuditSink | null = null,
) {}
afterInit(): void {
@@ -77,6 +152,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
async handleConnection(client: Socket): Promise<void> {
const serviceToken = client.handshake.auth['discordServiceToken'];
if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) {
client.data.discordService = true;
this.logger.log(`Authenticated Discord service connected: ${client.id}`);
return;
}
const session = await validateSocketSession(client.handshake.headers, this.auth);
if (!session) {
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
@@ -87,8 +169,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
client.data.user = session.user;
client.data.session = session.session;
this.logger.log(`Client connected: ${client.id}`);
// Broadcast command manifest to the newly connected client
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
}
@@ -97,25 +177,84 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const session = this.clientSessions.get(client.id);
if (session) {
session.cleanup();
this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`);
this.agentService.removeChannel(
session.conversationId,
`websocket:${client.id}`,
session.scope,
);
this.clientSessions.delete(client.id);
}
this.textEgressBuffers.delete(client.id);
this.thinkingEgressBuffers.delete(client.id);
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
}
private getClientScope(client: Socket): ActorTenantScope | null {
const user = client.data.user as AuthenticatedUserLike | undefined;
if (!user?.id) return null;
return scopeFromUser(user);
}
private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string {
return `${scope.tenantId}:${scope.userId}:${conversationId}`;
}
private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean {
return a.userId === b.userId && a.tenantId === b.tenantId;
}
@SubscribeMessage('message')
async handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: ChatSocketMessageDto,
@MessageBody() rawData: unknown,
): Promise<void> {
let discordIngress: DiscordIngressPayload | null = null;
let data: ChatSocketMessageDto;
if (client.data.discordService) {
if (!isDiscordIngressEnvelope(rawData)) {
this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`);
return;
}
discordIngress = this.resolveDiscordIngress(client, rawData);
if (!discordIngress) return;
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
} else {
if (!isChatSocketMessage(rawData)) {
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
return;
}
data = rawData;
}
const conversationId = data.conversationId ?? uuid();
const userId = (client.data.user as { id: string } | undefined)?.id;
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordIngress && !discordServiceUserId) {
this.logger.warn(
`Rejected Discord ingress without configured service owner from ${client.id}`,
);
return;
}
const scope = discordIngress
? {
userId: discordServiceUserId!,
tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!,
}
: this.getClientScope(client);
if (!scope) {
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
return;
}
const userId = scope.userId;
const correlationId = discordIngress?.correlationId;
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
this.logger.log(
`Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`,
);
// Ensure agent session exists for this conversation
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
try {
let agentSession = this.agentService.getSession(conversationId);
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
@@ -135,7 +274,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
let resolvedProvider = data.provider;
let resolvedModelId = data.modelId;
const modelOverride = modelOverrides.get(conversationId);
const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope));
if (modelOverride) {
// /model override bypasses routing engine (M4-007)
resolvedModelId = modelOverride;
@@ -172,6 +311,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
modelId: resolvedModelId,
agentConfigId: data.agentId,
userId,
tenantId: scope.tenantId,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
});
@@ -210,9 +350,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{
conversationId,
role: 'user',
content: data.content,
content: redactSensitiveContent(data.content).content,
metadata: {
timestamp: new Date().toISOString(),
...(correlationId
? {
correlationId,
discordMessageId: discordIngress?.messageId,
discordUserId: discordIngress?.userId,
}
: {}),
classifications: redactSensitiveContent(data.content).classifications,
},
},
userId,
@@ -232,9 +380,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
// Subscribe to agent events and relay to client
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
this.relayEvent(client, conversationId, event);
});
const cleanup = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
this.relayEvent(client, conversationId, event);
},
scope,
);
// Preserve routing decision from the existing client session if we didn't get a new one
const prevClientSession = this.clientSessions.get(client.id);
@@ -246,16 +398,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope,
lastRoutingDecision: routingDecisionToStore,
});
// Track channel connection
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope);
// Send session info so the client knows the model/provider (M4-008: include routing decision)
// Include agentName when a named agent config is active (M5-001)
{
const agentSession = this.agentService.getSession(conversationId);
const agentSession = this.agentService.getSession(conversationId, scope);
if (agentSession) {
const piSession = agentSession.piSession;
client.emit('session:info', {
@@ -271,11 +424,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
// Send acknowledgment
client.emit('message:ack', { conversationId, messageId: uuid() });
client.emit('message:ack', {
conversationId,
messageId: uuid(),
...(correlationId
? {
correlationId,
discordMessageId: discordIngress?.messageId,
discordUserId: discordIngress?.userId,
}
: {}),
});
// Dispatch to agent
try {
await this.agentService.prompt(conversationId, data.content);
await this.agentService.prompt(conversationId, data.content, scope);
} catch (err) {
this.logger.error(
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
@@ -293,7 +456,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@ConnectedSocket() client: Socket,
@MessageBody() data: SetThinkingPayload,
): void {
const session = this.agentService.getSession(data.conversationId);
const scope = this.getClientScope(client);
if (!scope) {
client.emit('error', {
conversationId: data.conversationId,
error: 'Authenticated user scope is required.',
});
return;
}
const session = this.agentService.getSession(data.conversationId, scope);
if (!session) {
client.emit('error', {
conversationId: data.conversationId,
@@ -334,7 +506,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const conversationId = data.conversationId;
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
const session = this.agentService.getSession(conversationId);
const scope = this.getClientScope(client);
if (!scope) {
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
return;
}
const session = this.agentService.getSession(conversationId, scope);
if (!session) {
client.emit('error', {
conversationId,
@@ -363,11 +541,45 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@ConnectedSocket() client: Socket,
@MessageBody() payload: SlashCommandPayload,
): Promise<void> {
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
const result = await this.commandExecutor.execute(payload, userId);
const scope = this.getClientScope(client);
if (!scope) {
client.emit('command:result', {
command: payload.command,
conversationId: payload.conversationId,
success: false,
message: 'Authenticated user scope is required.',
});
return;
}
const result = await this.commandExecutor.execute(payload, scope);
client.emit('command:result', result);
}
@SubscribeMessage('command:approve')
async handleCommandApproval(
@ConnectedSocket() client: Socket,
@MessageBody() payload: SlashCommandPayload,
): Promise<void> {
const scope = this.getClientScope(client);
const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null;
const result: SlashCommandApprovalResultPayload = approval
? {
command: payload.command,
conversationId: payload.conversationId,
success: true,
approvalId: approval.approvalId,
expiresAt: approval.expiresAt,
}
: {
command: payload.command,
conversationId: payload.conversationId,
success: false,
message: 'Not authorized to approve this command.',
};
client.emit('command:approval', result);
}
broadcastReload(payload: SystemReloadPayload): void {
this.server.emit('system:reload', payload);
this.logger.log('Broadcasted system:reload to all connected clients');
@@ -380,18 +592,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
* M5-005: Emits session:info to clients subscribed to this conversation when a model is set.
* M5-007: Records a model switch in session metrics.
*/
setModelOverride(conversationId: string, modelName: string | null): void {
setModelOverride(
conversationId: string,
modelName: string | null,
scope: ActorTenantScope,
): void {
const key = this.modelOverrideKey(conversationId, scope);
if (modelName) {
modelOverrides.set(conversationId, modelName);
modelOverrides.set(key, modelName);
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
// M5-002: Update the live session's modelId so session:info reflects the new model immediately
this.agentService.updateSessionModel(conversationId, modelName);
this.agentService.updateSessionModel(conversationId, modelName, scope);
// M5-005: Broadcast session:info to all clients subscribed to this conversation
this.broadcastSessionInfo(conversationId);
this.broadcastSessionInfo(conversationId, scope);
} else {
modelOverrides.delete(conversationId);
modelOverrides.delete(key);
this.logger.log(`Model override cleared: conversation=${conversationId}`);
}
}
@@ -399,8 +616,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
/**
* Return the active model override for a conversation, or undefined if none.
*/
getModelOverride(conversationId: string): string | undefined {
return modelOverrides.get(conversationId);
getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined {
return modelOverrides.get(this.modelOverrideKey(conversationId, scope));
}
/**
@@ -409,9 +626,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
*/
broadcastSessionInfo(
conversationId: string,
scope: ActorTenantScope,
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
): void {
const agentSession = this.agentService.getSession(conversationId);
const agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) return;
const piSession = agentSession.piSession;
@@ -428,7 +646,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
// Emit to all clients currently subscribed to this conversation
for (const [clientId, session] of this.clientSessions) {
if (session.conversationId === conversationId) {
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
const socket = this.server.sockets.sockets.get(clientId);
if (socket?.connected) {
socket.emit('session:info', payload);
@@ -442,6 +660,233 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
* Creates it if absent — safe to call concurrently since a duplicate insert
* would fail on the PK constraint and be caught here.
*/
@SubscribeMessage('discord:approve')
async handleDiscordApproval(
@ConnectedSocket() client: Socket,
@MessageBody() envelope: DiscordIngressEnvelope,
): Promise<void> {
if (!client.data.discordService) return;
const ingress = this.resolveDiscordIngress(client, envelope, 'approve');
const isApprovalCommand = /^\/approve\s*$/i.test(ingress?.content ?? '');
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
if (
!ingress ||
!isApprovalCommand ||
!tenantId ||
!this.commandAuthorization ||
!this.durableSessions
)
return;
const binding = resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'approve',
);
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!actorId || !agentName || binding.instanceId !== agentName) {
this.logger.warn(
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
);
client.emit('discord:approval', {
correlationId: ingress.correlationId,
success: false,
approvalId: undefined,
expiresAt: undefined,
});
return;
}
let snapshot;
try {
snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, {
actorScope: { userId: actorId, tenantId },
channelId: ingress.channelId,
correlationId: ingress.correlationId,
});
} catch {
client.emit('discord:approval', {
correlationId: ingress.correlationId,
success: false,
approvalId: undefined,
expiresAt: undefined,
});
return;
}
if (snapshot.identity.agentName !== agentName) {
client.emit('discord:approval', {
correlationId: ingress.correlationId,
success: false,
approvalId: undefined,
expiresAt: undefined,
});
return;
}
const approval = await this.commandAuthorization.createRuntimeTerminationApproval({
providerId: snapshot.identity.providerId,
sessionId: snapshot.identity.runtimeSessionId,
actorId,
tenantId,
channelId: ingress.channelId,
correlationId: this.discordRuntimeActionCorrelation(
binding.instanceId,
ingress,
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
),
agentName,
});
if (!approval) {
await this.runtimeAudit?.record({
providerId: snapshot.identity.providerId,
operation: 'session.terminate',
outcome: 'denied',
actorId,
tenantId,
channelId: ingress.channelId,
correlationId: this.discordRuntimeActionCorrelation(
binding.instanceId,
ingress,
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
),
resourceId: snapshot.identity.runtimeSessionId,
errorCode: 'policy_denied',
});
}
client.emit('discord:approval', {
correlationId: ingress.correlationId,
success: approval !== null,
approvalId: approval?.approvalId,
expiresAt: approval?.expiresAt,
});
}
@SubscribeMessage('discord:stop')
async handleDiscordStop(
@ConnectedSocket() client: Socket,
@MessageBody() envelope: DiscordIngressEnvelope,
): Promise<void> {
if (!client.data.discordService) return;
const ingress = this.resolveDiscordIngress(client, envelope, 'stop');
const approvalRef = /^\/stop\s+([^\s]+)$/i.exec(ingress?.content ?? '')?.[1];
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
return;
const binding = resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'stop',
);
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
if (!actorId) return;
try {
const context = {
actorScope: { userId: actorId, tenantId },
channelId: ingress.channelId,
correlationId: ingress.correlationId,
};
const snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, context);
if (snapshot.identity.agentName !== binding.instanceId) throw new Error('agent mismatch');
// RuntimeProviderService consumes the durable approval exactly once using the
// provisioned approving-admin identity, never the Discord service account.
await this.runtimeRegistry.terminate(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
approvalRef,
{
...context,
correlationId: this.discordRuntimeActionCorrelation(
binding.instanceId,
ingress,
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
),
},
);
client.emit('discord:stop', { correlationId: ingress.correlationId, success: true });
} catch {
client.emit('discord:stop', { correlationId: ingress.correlationId, success: false });
}
}
/**
* Correlates the immutable termination target rather than either Discord message.
* Approval and stop are distinct ingress events, but must consume the same seven-field action.
*/
private discordRuntimeActionCorrelation(
instanceId: string,
ingress: DiscordIngressPayload,
providerId: string,
sessionId: string,
): string {
const target = [
instanceId,
ingress.guildId,
ingress.channelId,
ingress.conversationId,
providerId,
sessionId,
];
return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`;
}
private resolveDiscordIngress(
client: Socket,
envelope: DiscordIngressEnvelope,
operation: 'send' | 'approve' | 'stop' = 'send',
): DiscordIngressPayload | null {
const payload = verifyDiscordIngressEnvelope(
envelope,
process.env['DISCORD_SERVICE_TOKEN'] ?? '',
{
guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
},
);
if (!payload) {
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
return null;
}
try {
const binding = resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
payload.guildId,
payload.channelId,
payload.userId,
operation,
);
if (!binding) {
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
return null;
}
} catch {
this.logger.warn(
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
);
return null;
}
if (!this.discordReplayProtector.claim(payload.messageId)) {
this.logger.warn(
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
);
return null;
}
return payload;
}
private readDiscordAllowlist(name: string): string[] {
return (process.env[name] ?? '')
.split(',')
.map((id: string): string => id.trim())
.filter((id: string): boolean => id.length > 0);
}
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
try {
const existing = await this.brain.conversations.findById(conversationId, userId);
@@ -527,6 +972,127 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
}
private appendAndFlushRedactedEgress(
client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
buffers: Map<string, string>,
delta: string,
): void {
const key = this.egressKey(client, eventName);
if (this.overflowedEgress.has(key)) return;
const buffered = `${buffers.get(client.id) ?? ''}${delta}`;
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
buffers.delete(client.id);
this.overflowedEgress.add(key);
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
return;
}
buffers.set(client.id, buffered);
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
}
/**
* Holds any suffix that could become a secret, email, or phone number after a
* later stream chunk. This avoids relying on downstream redaction after data
* has already reached the socket.
*/
private flushRedactedEgress(
client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
buffers: Map<string, string>,
final: boolean,
): void {
const key = this.egressKey(client, eventName);
if (this.overflowedEgress.has(key)) {
if (final) this.overflowedEgress.delete(key);
return;
}
const buffered = buffers.get(client.id) ?? '';
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
const released = buffered.slice(0, releaseLength);
const pending = buffered.slice(releaseLength);
if (pending) {
buffers.set(client.id, pending);
} else {
buffers.delete(client.id);
}
if (released) {
client.emit(eventName, {
conversationId,
text: redactSensitiveContent(released).content,
});
}
}
private safeRedactionPrefixLength(content: string): number {
let retainedFrom = content.length;
// Retain the current token because it may become a split secret or email.
const token = /(?:^|\s)(\S*)$/.exec(content);
if (token) {
const matched = token[0] ?? '';
const trailingToken = token[1] ?? '';
retainedFrom = token.index + matched.length - trailingToken.length;
}
// The secret classifier accepts whitespace around ':' and '=', so preserve
// a pending label until its value and delimiter are both complete.
const pendingSecretLabel =
/(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec(
content,
);
if (pendingSecretLabel) {
const label = pendingSecretLabel[1] ?? '';
retainedFrom = Math.min(
retainedFrom,
pendingSecretLabel.index + pendingSecretLabel[0].length - label.length,
);
}
const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec(
content,
);
if (secretLabel) {
retainedFrom = Math.min(retainedFrom, secretLabel.index);
}
// Phone numbers can contain whitespace and punctuation; preserve the full
// trailing numeric candidate until a non-phone character establishes a boundary.
const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content);
if (phone) {
const matched = phone[0] ?? '';
const trailingPhoneCandidate = phone[1] ?? '';
retainedFrom = Math.min(
retainedFrom,
phone.index + matched.length - trailingPhoneCandidate.length,
);
}
const privateKeyStart = content.lastIndexOf('-----BEGIN');
if (privateKeyStart >= 0) {
const privateKey = content.slice(privateKeyStart);
if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) {
// Release the complete block in one pass so the full-block classifier can redact it.
retainedFrom = content.length;
} else {
retainedFrom = Math.min(retainedFrom, privateKeyStart);
}
}
return retainedFrom;
}
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string {
return `${client.id}:${eventName}`;
}
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
if (!client.connected) {
this.logger.warn(
@@ -544,13 +1110,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
cs.toolCalls = [];
cs.pendingToolCalls.clear();
}
this.textEgressBuffers.set(client.id, '');
this.thinkingEgressBuffers.set(client.id, '');
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
client.emit('agent:start', { conversationId });
break;
}
case 'agent_end': {
// Gather usage stats from the Pi session
const agentSession = this.agentService.getSession(conversationId);
const activeClientSession = this.clientSessions.get(client.id);
const agentSession = activeClientSession
? this.agentService.getSession(conversationId, activeClientSession.scope)
: undefined;
const piSession = agentSession?.piSession;
const stats = piSession?.getSessionStats();
const contextUsage = piSession?.getContextUsage();
@@ -569,6 +1142,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
: undefined;
this.flushRedactedEgress(
client,
conversationId,
'agent:text',
this.textEgressBuffers,
true,
);
this.flushRedactedEgress(
client,
conversationId,
'agent:thinking',
this.thinkingEgressBuffers,
true,
);
client.emit('agent:end', {
conversationId,
usage: usagePayload,
@@ -611,8 +1198,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{
conversationId,
role: 'assistant',
content: cs.assistantText,
metadata,
content: redactSensitiveContent(cs.assistantText).content,
metadata: {
...metadata,
classifications: redactSensitiveContent(cs.assistantText).classifications,
},
},
userId,
)
@@ -634,20 +1224,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'message_update': {
const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type === 'text_delta') {
// Accumulate assistant text for persistence
// Keep raw stream material in memory only; persist and emit only redacted text.
const cs = this.clientSessions.get(client.id);
if (cs) {
cs.assistantText += assistantEvent.delta;
}
client.emit('agent:text', {
this.appendAndFlushRedactedEgress(
client,
conversationId,
text: assistantEvent.delta,
});
'agent:text',
this.textEgressBuffers,
assistantEvent.delta,
);
} else if (assistantEvent.type === 'thinking_delta') {
client.emit('agent:thinking', {
this.appendAndFlushRedactedEgress(
client,
conversationId,
text: assistantEvent.delta,
});
'agent:thinking',
this.thinkingEgressBuffers,
assistantEvent.delta,
);
}
break;
}
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
const adminCommand: CommandDef = {
name: 'gc',
description: 'GC',
aliases: [],
scope: 'admin',
execution: 'socket',
available: true,
};
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
function createService(
role: string,
entries: Map<string, string> = new Map<string, string>(),
): CommandAuthorizationService {
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('CommandAuthorizationService', () => {
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(true);
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
const mutated = { ...payload, conversationId: 'other-conversation' };
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
const service = createService('member');
const approval = await service.createApproval(adminCommand, payload, 'member-1');
expect(approval).toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
).toBe(false);
});
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
providerId: 'fleet',
sessionId: 'nova',
actorId: 'admin-1',
tenantId: 'tenant-1',
channelId: 'discord:operator',
correlationId: 'correlation-malformed-expiry',
agentName: 'Nova',
};
const service = createService('admin', entries);
const approval = await service.createRuntimeTerminationApproval(action);
expect(approval).not.toBeNull();
const key = `agent:Nova:command-approval:${approval!.approvalId}`;
const stored = entries.get(key);
expect(stored).toBeDefined();
entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' }));
expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
false,
);
});
it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
providerId: 'fleet',
sessionId: 'nova',
actorId: 'admin-1',
tenantId: 'tenant-1',
channelId: 'discord:operator',
correlationId: 'correlation-1',
agentName: 'Nova',
};
const beforeRestart = createService('admin', entries);
const approval = await beforeRestart.createRuntimeTerminationApproval(action);
const afterRestart = createService('admin', entries);
expect(
await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, {
...action,
sessionId: 'forged-session',
}),
).toBe(false);
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
true,
);
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
false,
);
});
});
@@ -0,0 +1,268 @@
import { createHash, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
export type CommandRole = 'admin' | 'member' | 'viewer';
export interface CommandApproval {
approvalId: string;
actionDigest: string;
actorId: string;
command: string;
expiresAt: string;
}
/** Exact immutable binding for a privileged runtime termination. */
export interface RuntimeTerminationApprovalAction {
providerId: string;
sessionId: string;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
/** Provisioned roster identity; isolates approvals between interaction agents. */
agentName: string;
}
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
approvalId: string;
actionDigest: string;
expiresAt: string;
}
export interface CommandAuthorizationResult {
allowed: boolean;
reason?: string;
}
@Injectable()
export class CommandAuthorizationService {
constructor(
@Inject(DB) private readonly db: Db,
@Inject(COMMANDS_REDIS)
private readonly redis: {
get(key: string): Promise<string | null>;
set(key: string, value: string, ...args: string[]): Promise<unknown>;
del(key: string): Promise<number>;
},
) {}
async authorize(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
approvalId?: string,
): Promise<CommandAuthorizationResult> {
const role = await this.resolveRole(actorId);
if (!role || !this.hasScope(role, command.scope)) {
return { allowed: false, reason: 'not authorized for this command scope' };
}
if (command.scope !== 'admin') return { allowed: true };
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
const actionDigest = this.actionDigest(command.name, payload);
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
return approved
? { allowed: true }
: {
allowed: false,
reason: 'approval is invalid, expired, replayed, or does not match this action',
};
}
async createApproval(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
): Promise<CommandApproval | null> {
const role = await this.resolveRole(actorId);
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
const approvalId = randomUUID();
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
const approval: CommandApproval = {
approvalId,
actionDigest: this.actionDigest(command.name, payload),
actorId,
command: command.name,
expiresAt,
};
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
return approval;
}
/**
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
* command approvals. This deliberately avoids a parallel approval database.
*/
async createRuntimeTerminationApproval(
action: RuntimeTerminationApprovalAction,
): Promise<RuntimeTerminationApproval | null> {
if (!this.hasRuntimeTerminationAction(action)) return null;
const role = await this.resolveRole(action.actorId);
if (role !== 'admin') return null;
const approval: RuntimeTerminationApproval = {
approvalId: randomUUID(),
actionDigest: this.runtimeActionDigest(action),
...action,
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
};
await this.redis.set(
this.runtimeKey(action.agentName, approval.approvalId),
JSON.stringify(approval),
'EX',
'300',
);
return approval;
}
async consumeRuntimeTerminationApproval(
approvalId: string,
action: RuntimeTerminationApprovalAction,
): Promise<boolean> {
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
if (!encoded) return false;
let approval: unknown;
try {
approval = JSON.parse(encoded);
} catch {
return false;
}
if (
!this.isRuntimeTerminationApproval(approval) ||
approval.actionDigest !== this.runtimeActionDigest(action) ||
approval.actorId !== action.actorId ||
approval.tenantId !== action.tenantId ||
!this.isUnexpired(approval.expiresAt)
) {
return false;
}
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
}
private async resolveRole(actorId: string): Promise<CommandRole | null> {
const [user] = await this.db
.select({ role: usersTable.role })
.from(usersTable)
.where(eq(usersTable.id, actorId))
.limit(1);
const role = user?.role;
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (role === 'admin') return true;
return role === 'member' && (scope === 'core' || scope === 'agent');
}
private async consumeApproval(
approvalId: string,
actorId: string,
actionDigest: string,
): Promise<boolean> {
const key = this.key(approvalId);
const encoded = await this.redis.get(key);
if (!encoded) return false;
let parsed: unknown;
try {
parsed = JSON.parse(encoded);
} catch {
return false;
}
if (
!this.isCommandApproval(parsed) ||
parsed.actorId !== actorId ||
parsed.actionDigest !== actionDigest ||
!this.isUnexpired(parsed.expiresAt)
)
return false;
return (await this.redis.del(key)) === 1;
}
private actionDigest(command: string, payload: SlashCommandPayload): string {
return createHash('sha256')
.update(
JSON.stringify({
command,
args: payload.args?.trim() ?? '',
conversationId: payload.conversationId,
}),
)
.digest('hex');
}
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
return [
action.providerId,
action.sessionId,
action.actorId,
action.tenantId,
action.channelId,
action.correlationId,
action.agentName,
].every((value: string): boolean => value.trim().length > 0);
}
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
return createHash('sha256')
.update(
JSON.stringify({
providerId: action.providerId,
sessionId: action.sessionId,
actorId: action.actorId,
tenantId: action.tenantId,
channelId: action.channelId,
correlationId: action.correlationId,
agentName: action.agentName,
}),
)
.digest('hex');
}
private isUnexpired(expiresAt: unknown): expiresAt is string {
if (typeof expiresAt !== 'string') return false;
const expiresAtMs = Date.parse(expiresAt);
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
}
private isCommandApproval(value: unknown): value is CommandApproval {
return (
typeof value === 'object' &&
value !== null &&
'approvalId' in value &&
'actionDigest' in value &&
'actorId' in value &&
'expiresAt' in value &&
'command' in value
);
}
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
return (
typeof value === 'object' &&
value !== null &&
'approvalId' in value &&
'actionDigest' in value &&
'actorId' in value &&
'tenantId' in value &&
'providerId' in value &&
'sessionId' in value &&
'channelId' in value &&
'correlationId' in value &&
'agentName' in value &&
'expiresAt' in value
);
}
private key(approvalId: string): string {
return `interaction:command-approval:${approvalId}`;
}
private runtimeKey(agentName: string, approvalId: string): string {
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
}
}
@@ -89,6 +89,7 @@ function buildService(): CommandExecutorService {
describe('CommandExecutorService — P8-012 commands', () => {
let service: CommandExecutorService;
const userId = 'user-123';
const userScope = { userId, tenantId: userId };
const conversationId = 'conv-456';
beforeEach(() => {
@@ -99,31 +100,26 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider login — missing provider name
it('/provider login with no provider name returns usage error', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider login');
expect(result.command).toBe('provider');
});
// /provider login anthropic — success with URL containing poll token
it('/provider login <name> returns success with URL and poll token', async () => {
// /provider login anthropic — no bearer token or auth URL reaches chat output
it('/provider login <name> keeps its one-time token out of chat output', async () => {
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('provider');
expect(result.message).toContain('anthropic');
expect(result.message).toContain('http');
// data should contain loginUrl and pollToken
expect(result.data).toBeDefined();
const data = result.data as Record<string, unknown>;
expect(typeof data['loginUrl']).toBe('string');
expect(typeof data['pollToken']).toBe('string');
expect(data['loginUrl'] as string).toContain('anthropic');
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
expect(result.message).not.toContain('http');
expect(result.message).not.toContain('token=');
expect(result.data).toEqual({ provider: 'anthropic' });
// Verify Valkey was called
expect(mockRedis.set).toHaveBeenCalledOnce();
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
@@ -138,7 +134,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider with no args — returns usage
it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /provider');
});
@@ -146,7 +142,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider list
it('/provider list returns success', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('provider');
});
@@ -154,7 +150,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider logout with no name — usage error
it('/provider logout with no name returns error', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider logout');
});
@@ -166,7 +162,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'unknown',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Unknown subcommand');
});
@@ -174,7 +170,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /mission status
it('/mission status returns stub message', async () => {
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('mission');
expect(result.message).toContain('Mission status');
@@ -183,7 +179,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /mission with no args
it('/mission with no args returns status stub', async () => {
const payload: SlashCommandPayload = { command: 'mission', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Mission status');
});
@@ -195,7 +191,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'set my-mission-123',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('my-mission-123');
});
@@ -203,7 +199,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /agent list
it('/agent list returns stub message', async () => {
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('agent');
expect(result.message).toContain('agent');
@@ -212,7 +208,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /agent with no args
it('/agent with no args returns usage', async () => {
const payload: SlashCommandPayload = { command: 'agent', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /agent');
});
@@ -224,7 +220,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'my-agent-id',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('my-agent-id');
});
@@ -232,7 +228,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /prdy
it('/prdy returns PRD wizard message', async () => {
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('prdy');
expect(result.message).toContain('mosaic prdy');
@@ -241,7 +237,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /tools
it('/tools returns tools stub message', async () => {
const payload: SlashCommandPayload = { command: 'tools', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('tools');
expect(result.message).toContain('tools');
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
const registry = {
getManifest: vi.fn(() => ({
version: 1,
commands: [
{
name: 'gc',
description: 'System-wide garbage collection',
aliases: [],
scope: 'admin' as const,
execution: 'socket' as const,
available: true,
},
],
skills: [],
})),
};
const sessionGc = {
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
};
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
const authorization = {
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
Promise.resolve(
actorId === 'member-1'
? { allowed: false, reason: 'durable approval is required' }
: { allowed: false, reason: 'not authorized for this command scope' },
),
),
};
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
return new CommandExecutorService(
registry as never,
{ getSession: vi.fn() } as never,
{ clear: vi.fn(), set: vi.fn() } as never,
sessionGc as never,
{ set: vi.fn() } as never,
{ agents: {} } as never,
null,
null,
null,
authorizationService as never,
);
}
function createDurableAuthorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
beforeEach((): void => {
vi.clearAllMocks();
});
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
expect(result.success).toBe(false);
expect(result.message).toContain('not authorized');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('member-1'));
expect(result.success).toBe(false);
expect(result.message).toContain('approval');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
const executor = buildExecutor(createDurableAuthorization());
const adminScope = scope('admin-1');
const denied = await executor.execute(payload, adminScope);
const approval = await executor.createApproval(payload, adminScope);
const approved = await executor.execute(
{ ...payload, approvalId: approval?.approvalId },
adminScope,
);
expect(denied.success).toBe(false);
expect(denied.message).toContain('approval');
expect(approval).not.toBeNull();
// A valid durable approval is consumed, but cannot authorize an unimplemented
// global retention operation. Session-scoped cleanup remains lifecycle-only.
expect(approved.success).toBe(false);
expect(approved.message).toContain('Global GC is disabled');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
});
@@ -3,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue';
import type { Brain } from '@mosaicstack/brain';
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
import { AgentService } from '../agent/agent.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { ChatGateway } from '../chat/chat.gateway.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
@@ -10,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js';
import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandRegistryService } from './command-registry.service.js';
@Injectable()
@@ -32,10 +34,17 @@ export class CommandExecutorService {
@Optional()
@Inject(McpClientService)
private readonly mcpClient: McpClientService | null,
@Optional()
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService | null = null,
) {}
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
async execute(
payload: SlashCommandPayload,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
const { command, args, conversationId } = payload;
const userId = scope.userId;
const def = this.registry.getManifest().commands.find((c) => c.name === command);
if (!def) {
@@ -47,14 +56,24 @@ export class CommandExecutorService {
};
}
const authorization = await this.authorization?.authorize(
def,
payload,
userId,
payload.approvalId,
);
if (authorization && !authorization.allowed) {
return { command, conversationId, success: false, message: authorization.reason };
}
try {
switch (command) {
case 'model':
return await this.handleModel(args ?? null, conversationId);
return await this.handleModel(args ?? null, conversationId, scope);
case 'thinking':
return await this.handleThinking(args ?? null, conversationId);
case 'system':
return await this.handleSystem(args ?? null, conversationId);
return await this.handleSystem(args ?? null, conversationId, scope);
case 'new':
return {
command,
@@ -83,18 +102,17 @@ export class CommandExecutorService {
success: true,
message: 'Retry last message requested.',
};
case 'gc': {
// Admin-only: system-wide GC sweep across all sessions
const result = await this.sessionGC.sweepOrphans();
case 'gc':
// Global retention requires a separate, authorized and audited job.
// Session cleanup is performed only through the session lifecycle.
return {
command: 'gc',
success: true,
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
success: false,
message: 'Global GC is disabled pending an authorized retention job.',
conversationId,
};
}
case 'agent':
return await this.handleAgent(args ?? null, conversationId, userId);
return await this.handleAgent(args ?? null, conversationId, scope);
case 'provider':
return await this.handleProvider(args ?? null, userId, conversationId);
case 'mission':
@@ -143,13 +161,22 @@ export class CommandExecutorService {
}
}
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
const def = this.registry
.getManifest()
.commands.find((command) => command.name === payload.command);
if (!def || !this.authorization) return null;
return this.authorization.createApproval(def, payload, scope.userId);
}
private async handleModel(
args: string | null,
conversationId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
if (!args || args.trim().length === 0) {
// Show current override or usage hint
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
if (currentOverride) {
return {
command: 'model',
@@ -171,7 +198,7 @@ export class CommandExecutorService {
// /model clear removes the override and re-enables automatic routing
if (modelName === 'clear') {
this.chatGateway?.setModelOverride(conversationId, null);
this.chatGateway?.setModelOverride(conversationId, null, scope);
return {
command: 'model',
conversationId,
@@ -181,9 +208,9 @@ export class CommandExecutorService {
}
// Set the sticky per-session override (M4-007)
this.chatGateway?.setModelOverride(conversationId, modelName);
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
const session = this.agentService.getSession(conversationId);
const session = this.agentService.getSession(conversationId, scope);
if (!session) {
return {
command: 'model',
@@ -224,10 +251,11 @@ export class CommandExecutorService {
private async handleSystem(
args: string | null,
conversationId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
if (!args || args.trim().length === 0) {
// Clear the override when called with no args
await this.systemOverride.clear(conversationId);
await this.systemOverride.clear(conversationId, scope);
return {
command: 'system',
conversationId,
@@ -236,7 +264,7 @@ export class CommandExecutorService {
};
}
await this.systemOverride.set(conversationId, args.trim());
await this.systemOverride.set(conversationId, args.trim(), scope);
return {
command: 'system',
conversationId,
@@ -248,8 +276,9 @@ export class CommandExecutorService {
private async handleAgent(
args: string | null,
conversationId: string,
userId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
const userId = scope.userId;
if (!args) {
return {
command: 'agent',
@@ -338,11 +367,14 @@ export class CommandExecutorService {
conversationId,
agentConfig.id,
agentConfig.name,
scope,
agentConfig.model ?? undefined,
);
// Broadcast updated session:info so TUI TopBar reflects new agent/model
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
agentName: agentConfig.name,
});
this.logger.log(
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
@@ -403,22 +435,28 @@ export class CommandExecutorService {
};
}
const pollToken = crypto.randomUUID();
const key = `mosaic:auth:poll:${pollToken}`;
// Store pending state in Valkey (TTL 5 minutes)
const tokenDigest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(pollToken),
);
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
byte.toString(16).padStart(2, '0'),
).join('');
const key = `mosaic:auth:poll:${tokenHash}`;
// Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set(
key,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
// In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return {
command: 'provider',
success: true,
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
conversationId,
data: { loginUrl, pollToken, provider: providerName },
data: { provider: providerName },
};
}
@@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => {
let registry: CommandRegistryService;
let executor: CommandExecutorService;
const userId = 'user-integ-001';
const userScope = { userId, tenantId: userId };
const conversationId = 'conv-integ-001';
beforeEach(() => {
@@ -170,28 +171,26 @@ describe('CommandExecutorService — integration', () => {
// Unknown command returns error
it('unknown command returns success:false with descriptive message', async () => {
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('nonexistent');
expect(result.command).toBe('nonexistent');
});
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
it('/gc refuses an unaudited global sweep', async () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId };
const result = await executor.execute(payload, userId);
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
expect(result.success).toBe(true);
expect(result.message).toContain('GC sweep complete');
expect(result.message).toContain('3 orphaned sessions');
const result = await executor.execute(payload, userScope);
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
expect(result.success).toBe(false);
expect(result.message).toContain('disabled pending an authorized retention job');
});
// /system with args calls SystemOverrideService.set
it('/system with text calls SystemOverrideService.set', async () => {
const override = 'You are a helpful assistant.';
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
const result = await executor.execute(payload, userId);
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('override set');
});
@@ -199,8 +198,8 @@ describe('CommandExecutorService — integration', () => {
// /system with no args clears the override
it('/system with no args calls SystemOverrideService.clear', async () => {
const payload: SlashCommandPayload = { command: 'system', conversationId };
const result = await executor.execute(payload, userId);
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('cleared');
});
@@ -212,7 +211,7 @@ describe('CommandExecutorService — integration', () => {
args: 'claude-3-opus',
conversationId,
};
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('model');
expect(result.message).toContain('claude-3-opus');
@@ -221,7 +220,7 @@ describe('CommandExecutorService — integration', () => {
// /thinking with valid level returns success
it('/thinking with valid level returns success', async () => {
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('high');
});
@@ -229,7 +228,7 @@ describe('CommandExecutorService — integration', () => {
// /thinking with invalid level returns usage message
it('/thinking with invalid level returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage:');
});
@@ -237,7 +236,7 @@ describe('CommandExecutorService — integration', () => {
// /new command returns success
it('/new returns success', async () => {
const payload: SlashCommandPayload = { command: 'new', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('new');
});
@@ -245,7 +244,7 @@ describe('CommandExecutorService — integration', () => {
// /reload without reloadService returns failure
it('/reload without ReloadService returns failure', async () => {
const payload: SlashCommandPayload = { command: 'reload', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('ReloadService');
});
@@ -255,7 +254,7 @@ describe('CommandExecutorService — integration', () => {
for (const cmd of stubCommands) {
it(`/${cmd} returns success (stub)`, async () => {
const payload: SlashCommandPayload = { command: cmd, conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe(cmd);
});
+10 -1
View File
@@ -3,8 +3,10 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
import { CommandRegistryService } from './command-registry.service.js';
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
@@ -24,9 +26,16 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
inject: [COMMANDS_QUEUE_HANDLE],
},
CommandRegistryService,
CommandAuthorizationService,
CommandRuntimeApprovalVerifier,
CommandExecutorService,
],
exports: [
CommandRegistryService,
CommandAuthorizationService,
CommandRuntimeApprovalVerifier,
CommandExecutorService,
],
exports: [CommandRegistryService, CommandExecutorService],
})
export class CommandsModule implements OnApplicationShutdown {
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
@@ -0,0 +1,23 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
RuntimeApprovalVerifier,
RuntimeTerminationAction,
} from '../agent/runtime-provider-registry.service.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
/**
* Adapter from the provider registry's exact termination action to the shared,
* Redis-backed `interaction:command-approval:*` store. It has no separate approval
* persistence or replay semantics.
*/
@Injectable()
export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
constructor(
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService,
) {}
async consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean> {
return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action);
}
}
+25 -3
View File
@@ -1,10 +1,32 @@
import { Module } from '@nestjs/common';
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
import { CoordService } from './coord.service.js';
import { CoordController } from './coord.controller.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
import {
COORDINATION_CONFIG,
COORDINATION_PORT,
InteractionCoordinationService,
} from './interaction-coordination.service.js';
@Module({
providers: [CoordService],
controllers: [CoordController],
exports: [CoordService],
providers: [
CoordService,
{
provide: COORDINATION_PORT,
useFactory: (): InMemoryInteractionCoordinationPort =>
new InMemoryInteractionCoordinationPort(),
},
{
provide: COORDINATION_CONFIG,
useFactory: () => ({
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
}),
},
InteractionCoordinationService,
],
controllers: [CoordController, InteractionCoordinationController],
exports: [CoordService, InteractionCoordinationService],
})
export class CoordModule {}
@@ -0,0 +1,47 @@
const PATH_METADATA = 'path';
import { describe, expect, it, vi } from 'vitest';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
const user = { id: 'operator-1', tenantId: 'tenant-a' };
describe('InteractionCoordinationController', () => {
it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => {
expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([
'api/coord/interaction',
'api/coord/mos',
]);
expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function');
expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function');
expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function');
});
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
const coordination = {
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
observe: vi.fn(),
result: vi.fn(),
};
const controller = new InteractionCoordinationController(coordination as never);
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
expect(coordination.handoff).toHaveBeenCalledWith(
{ idempotencyKey: 'request-1', summary: 'Implement' },
expect.objectContaining({
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
channelId: 'cli',
correlationId: 'corr-1',
}),
);
});
it('requires a correlation header before invoking the coordination service', async () => {
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
const controller = new InteractionCoordinationController(coordination as never);
await expect(
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
).rejects.toThrow('X-Correlation-Id is required');
expect(coordination.handoff).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,75 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Inject,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type {
InteractionCoordinationObservationDto,
InteractionCoordinationResponseDto,
InteractionCoordinationResultDto,
CreateHandoffDto,
} from './interaction-coordination.dto.js';
import { InteractionCoordinationService } from './interaction-coordination.service.js';
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
@Controller(['api/coord/interaction', 'api/coord/mos'])
@UseGuards(AuthGuard)
export class InteractionCoordinationController {
constructor(
@Inject(InteractionCoordinationService)
private readonly coordination: InteractionCoordinationService,
) {}
@Post('handoff')
async handoff(
@Body() request: CreateHandoffDto,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<InteractionCoordinationResponseDto> {
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
}
@Get(':handoffId/observe')
async observe(
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<InteractionCoordinationObservationDto> {
return {
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
};
}
@Get(':handoffId/result')
async result(
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<InteractionCoordinationResultDto> {
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
}
private context(
user: AuthenticatedUserLike,
correlationId?: string,
): RuntimeProviderRequestContext {
const requestCorrelationId = correlationId?.trim();
if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required');
return {
actorScope: scopeFromUser(user),
channelId: 'cli',
correlationId: requestCorrelationId,
};
}
}
@@ -0,0 +1,25 @@
import type {
CoordinationObservation,
CoordinationResult,
HandoffReceipt,
} from '@mosaicstack/coord';
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
export interface CreateHandoffDto {
idempotencyKey: string;
summary: string;
context?: string;
missionId?: string;
}
export interface InteractionCoordinationResponseDto {
receipt: HandoffReceipt;
}
export interface InteractionCoordinationObservationDto {
observation: CoordinationObservation;
}
export interface InteractionCoordinationResultDto {
result: CoordinationResult;
}
@@ -0,0 +1,88 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Global, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { AUTH } from '../auth/auth.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
import { InteractionCoordinationService } from './interaction-coordination.service.js';
@Global()
@Module({
providers: [
{
provide: AUTH,
useValue: {
api: {
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
headers.get('cookie') === 'session=trusted'
? { user: { id: 'operator-1', tenantId: 'tenant-1' }, session: { id: 'session-1' } }
: null,
),
},
},
},
AuthGuard,
],
exports: [AUTH, AuthGuard],
})
class AuthenticatedRequestModule {}
describe('InteractionCoordinationController route aliases', (): void => {
let app: NestFastifyApplication | undefined;
const coordination = {
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
observe: vi.fn(async () => ({ status: 'running' })),
result: vi.fn(async () => ({ status: 'completed' })),
};
beforeAll(async (): Promise<void> => {
const moduleRef = await Test.createTestingModule({
imports: [AuthenticatedRequestModule],
controllers: [InteractionCoordinationController],
providers: [{ provide: InteractionCoordinationService, useValue: coordination }],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async (): Promise<void> => app?.close());
it('routes handoff, observe, and result through the same AuthGuard-protected service for both prefixes', async (): Promise<void> => {
if (!app) throw new Error('test app was not initialized');
for (const prefix of ['/api/coord/interaction', '/api/coord/mos']) {
const headers = { cookie: 'session=trusted', 'x-correlation-id': `corr-${prefix}` };
expect(
(
await app.inject({
method: 'POST',
url: `${prefix}/handoff`,
headers,
payload: { idempotencyKey: `key-${prefix}`, summary: 'handoff' },
})
).statusCode,
).toBe(201);
expect(
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/observe`, headers }))
.statusCode,
).toBe(200);
expect(
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/result`, headers }))
.statusCode,
).toBe(200);
}
expect(coordination.handoff).toHaveBeenCalledTimes(2);
expect(coordination.observe).toHaveBeenCalledTimes(2);
expect(coordination.result).toHaveBeenCalledTimes(2);
expect(
(
await app.inject({
method: 'POST',
url: '/api/coord/interaction/handoff',
payload: { idempotencyKey: 'denied', summary: 'x' },
})
).statusCode,
).toBe(401);
});
});
@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from 'vitest';
import {
InMemoryInteractionCoordinationPort,
type InteractionCoordinationPort,
type Handoff,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import {
InteractionCoordinationService,
type InteractionCoordinationConfig,
type InteractionCoordinationGatewayError,
} from './interaction-coordination.service.js';
const context: RuntimeProviderRequestContext = {
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
channelId: 'cli',
correlationId: 'corr-1',
};
const config: InteractionCoordinationConfig = {
interactionAgentId: 'Nova',
orchestrationAgentId: 'Conductor',
};
function service(
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
options: {
config?: InteractionCoordinationConfig;
handoffIdFactory?: () => string;
} = {},
): InteractionCoordinationService {
return new InteractionCoordinationService(
port,
options.config ?? config,
options.handoffIdFactory ?? (() => 'handoff-1'),
);
}
describe('InteractionCoordinationService authority boundary', (): void => {
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = service(adapter);
await expect(
coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).resolves.toEqual({
handoffId: 'handoff-1',
targetAgentId: 'Conductor',
status: 'queued',
correlationId: 'corr-1',
});
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
const followUpContext = { ...context, correlationId: 'corr-2' };
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
targetAgentId: 'Conductor',
status: 'completed',
});
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
targetAgentId: 'Conductor',
status: 'completed',
summary: 'Merged by orchestrator',
});
});
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
});
await expect(
coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).rejects.toMatchObject({
code: 'unconfigured_requester',
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(handoff).not.toHaveBeenCalled();
});
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
});
await expect(
coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).rejects.toThrow('Interaction and orchestration identities must differ');
expect(handoff).not.toHaveBeenCalled();
});
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const observe = vi.spyOn(adapter, 'observe');
const result = vi.spyOn(adapter, 'result');
const coordination = service(adapter);
await coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
);
const otherTenant = {
...context,
actorScope: { ...context.actorScope, tenantId: 'tenant-b' },
};
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(observe).not.toHaveBeenCalled();
expect(result).not.toHaveBeenCalled();
});
it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise<void> => {
let handoffSequence = 0;
let release: (() => void) | undefined;
const delivered = new Promise<void>((resolve: () => void): void => {
release = resolve;
});
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => {
await delivered;
return {
handoffId: handoff.handoffId,
targetAgentId: handoff.targetAgentId,
status: 'queued' as const,
correlationId: handoff.scope.correlationId,
};
}),
observe: vi.fn(),
result: vi.fn(),
};
const coordination = service(adapter, {
handoffIdFactory: (): string => `handoff-${++handoffSequence}`,
});
const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' };
const first = coordination.handoff(request, context);
const retry = coordination.handoff(request, context);
expect(adapter.handoff).toHaveBeenCalledTimes(1);
release?.();
await expect(Promise.all([first, retry])).resolves.toEqual([
expect.objectContaining({ handoffId: 'handoff-1' }),
expect.objectContaining({ handoffId: 'handoff-1' }),
]);
await expect(
coordination.handoff(request, {
...context,
actorScope: { ...context.actorScope, userId: 'operator-2' },
}),
).resolves.toMatchObject({ handoffId: 'handoff-2' });
expect(adapter.handoff).toHaveBeenCalledTimes(2);
});
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter);
await coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
);
await expect(
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
).rejects.toMatchObject({
code: 'handoff_conflict',
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(handoff).toHaveBeenCalledTimes(1);
});
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => ({
handoffId: handoff.handoffId,
targetAgentId: 'Unexpected',
status: 'accepted' as const,
correlationId: handoff.scope.correlationId,
})),
observe: vi.fn(),
result: vi.fn(),
};
await expect(
service(adapter).handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).rejects.toMatchObject({ code: 'target_drift' });
});
});
@@ -0,0 +1,303 @@
import { Inject, Injectable } from '@nestjs/common';
import {
InteractionCoordinationClient,
type CoordinationObservation,
type CoordinationResult,
type CoordinationScope,
type InteractionCoordinationIdentity,
type InteractionCoordinationPort,
type HandoffReceipt,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
const MAX_TRACKED_HANDOFFS = 1_000;
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
const MAX_SUMMARY_LENGTH = 2_048;
const MAX_CONTEXT_LENGTH = 8_192;
const MAX_MISSION_ID_LENGTH = 128;
export interface InteractionCoordinationConfig {
interactionAgentId?: string;
orchestrationAgentId?: string;
}
interface HandoffOwner {
actorId: string;
tenantId: string;
requesterAgentId: string;
correlationId: string;
expiresAt: number;
}
interface NormalizedHandoffRequest {
idempotencyKey: string;
summary: string;
context?: string;
missionId?: string;
}
interface TrackedHandoff {
request: NormalizedHandoffRequest;
receipt: Promise<HandoffReceipt>;
expiresAt: number;
}
/**
* Gateway authority boundary for the interaction agent. It derives requester,
* actor, and tenant from trusted server configuration and authentication; no
* channel request can name a target or gain orchestrator-owned orchestration verbs.
*/
@Injectable()
export class InteractionCoordinationService {
private readonly owners = new Map<string, HandoffOwner>();
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
constructor(
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {}
async handoff(
request: CreateHandoffDto,
context: RuntimeProviderRequestContext,
): Promise<HandoffReceipt> {
this.pruneExpiredTracking();
const normalized = this.normalizeRequest(request);
const scope = this.scope(context);
const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope);
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
if (existing !== undefined) {
if (!sameRequest(existing.request, normalized)) {
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Handoff idempotency key is already bound to different immutable input',
);
}
return existing.receipt;
}
const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope);
const tracked: TrackedHandoff = {
request: normalized,
receipt: pending,
expiresAt: this.expiresAt(),
};
this.handoffsByIdempotencyKey.set(idempotencyKey, tracked);
this.enforceTrackingLimit(this.handoffsByIdempotencyKey);
try {
return await pending;
} catch (error: unknown) {
if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) {
this.handoffsByIdempotencyKey.delete(idempotencyKey);
}
throw error;
}
}
async observe(
handoffId: string,
context: RuntimeProviderRequestContext,
): Promise<CoordinationObservation> {
this.pruneExpiredTracking();
const scope = this.scope(context);
const owner = this.ownerFor(handoffId, scope);
return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId });
}
async result(
handoffId: string,
context: RuntimeProviderRequestContext,
): Promise<CoordinationResult> {
this.pruneExpiredTracking();
const scope = this.scope(context);
const owner = this.ownerFor(handoffId, scope);
return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId });
}
private async deliverHandoff(
handoffId: string,
request: NormalizedHandoffRequest,
scope: CoordinationScope,
): Promise<HandoffReceipt> {
const receipt = await this.client((): string => handoffId).handoff(request, scope);
const owner: HandoffOwner = {
actorId: scope.actorId,
tenantId: scope.tenantId,
requesterAgentId: scope.requesterAgentId,
correlationId: scope.correlationId,
expiresAt: this.expiresAt(),
};
const existing = this.owners.get(receipt.handoffId);
if (existing !== undefined && !sameOwner(existing, owner)) {
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Handoff ID is already bound to a different authenticated scope',
);
}
this.owners.set(receipt.handoffId, owner);
this.enforceTrackingLimit(this.owners);
return receipt;
}
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
}
private identity(): InteractionCoordinationIdentity {
const interactionAgentId = this.config.interactionAgentId?.trim();
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
if (!interactionAgentId) {
throw new InteractionCoordinationGatewayError(
'unconfigured_requester',
'Interaction agent identity is not configured',
);
}
if (!orchestrationAgentId) {
throw new InteractionCoordinationGatewayError(
'unconfigured_target',
'Orchestration agent identity is not configured',
);
}
return { interactionAgentId, orchestrationAgentId };
}
private scope(context: RuntimeProviderRequestContext): CoordinationScope {
const identity = this.identity();
return Object.freeze({
actorId: context.actorScope.userId,
tenantId: context.actorScope.tenantId,
correlationId: context.correlationId,
requesterAgentId: identity.interactionAgentId,
});
}
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
if (typeof request !== 'object' || request === null) {
throw new InteractionCoordinationGatewayError(
'invalid_request',
'Handoff request is invalid',
);
}
const idempotencyKey = this.requiredString(
request.idempotencyKey,
'idempotency key',
MAX_IDEMPOTENCY_KEY_LENGTH,
);
const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH);
const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH);
const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH);
return Object.freeze({
idempotencyKey,
summary,
...(context === undefined ? {} : { context }),
...(missionId === undefined ? {} : { missionId }),
});
}
private idempotencyKey(requestKey: string, scope: CoordinationScope): string {
return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`;
}
private requiredString(value: unknown, field: string, maximumLength: number): string {
if (typeof value !== 'string') {
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Handoff ${field} must be a string`,
);
}
const normalized = value.trim();
if (normalized.length === 0 || normalized.length > maximumLength) {
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Handoff ${field} is invalid`,
);
}
return normalized;
}
private optionalString(value: unknown, field: string, maximumLength: number): string | undefined {
if (value === undefined) return undefined;
return this.requiredString(value, field, maximumLength);
}
private expiresAt(): number {
return Date.now() + HANDOFF_TRACKING_TTL_MS;
}
private pruneExpiredTracking(): void {
const now = Date.now();
for (const [key, tracked] of this.handoffsByIdempotencyKey) {
if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key);
}
for (const [key, owner] of this.owners) {
if (owner.expiresAt <= now) this.owners.delete(key);
}
}
private enforceTrackingLimit<T>(entries: Map<string, T>): void {
while (entries.size > MAX_TRACKED_HANDOFFS) {
const oldest = entries.keys().next().value;
if (typeof oldest !== 'string') return;
entries.delete(oldest);
}
}
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
const owner = this.owners.get(handoffId);
if (owner === undefined) {
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
}
if (
owner.tenantId !== scope.tenantId ||
owner.actorId !== scope.actorId ||
owner.requesterAgentId !== scope.requesterAgentId
) {
throw new InteractionCoordinationGatewayError(
'cross_tenant_forbidden',
'Handoff is outside the authenticated scope',
);
}
return owner;
}
}
export type InteractionCoordinationGatewayErrorCode =
| 'cross_tenant_forbidden'
| 'handoff_conflict'
| 'invalid_request'
| 'not_found'
| 'unconfigured_requester'
| 'unconfigured_target';
function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
return (
left.actorId === right.actorId &&
left.tenantId === right.tenantId &&
left.requesterAgentId === right.requesterAgentId
);
}
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
return (
left.idempotencyKey === right.idempotencyKey &&
left.summary === right.summary &&
left.context === right.context &&
left.missionId === right.missionId
);
}
export class InteractionCoordinationGatewayError extends Error {
constructor(
readonly code: InteractionCoordinationGatewayErrorCode,
message: string,
) {
super(message);
this.name = InteractionCoordinationGatewayError.name;
}
}
+41 -3
View File
@@ -1,8 +1,21 @@
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createDb, createPgliteDb, type Db, type DbHandle } from '@mosaicstack/db';
import {
Global,
Inject,
Logger,
Module,
type OnApplicationShutdown,
type OnModuleInit,
} from '@nestjs/common';
import {
createDb,
createPgliteDb,
runPgliteMigrations,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import { createStorageAdapter, type StorageAdapter } from '@mosaicstack/storage';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
@@ -39,12 +52,37 @@ export const STORAGE_ADAPTER = 'STORAGE_ADAPTER';
],
exports: [DB, STORAGE_ADAPTER],
})
export class DatabaseModule implements OnApplicationShutdown {
export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
private readonly logger = new Logger(DatabaseModule.name);
constructor(
@Inject(DB_HANDLE) private readonly handle: DbHandle,
@Inject(STORAGE_ADAPTER) private readonly storageAdapter: StorageAdapter,
@Inject(MOSAIC_CONFIG) private readonly config: MosaicConfig,
) {}
// Migrations must complete before any module that injects DB starts serving
// requests. NestJS awaits onModuleInit before app.listen(), and modules that
// inject DB are initialized after this one — so all DB-dependent code sees a
// populated schema before the first HTTP request lands.
//
// Local (PGlite) tier: we run gateway-DB migrations explicitly here. The
// storage adapter writes to a separate PGlite directory and only manages its
// own KV tables, so we still call its migrate() afterwards.
//
// Postgres tier: PostgresAdapter.migrate() already calls runMigrations() on
// the same DATABASE_URL, so a single call covers both the gateway DB and
// the storage tables. We deliberately do NOT call runMigrations() here to
// avoid opening a second short-lived connection and doubling startup cost.
async onModuleInit(): Promise<void> {
if (this.config.tier === 'local') {
this.logger.log('Applying PGlite schema migrations...');
await runPgliteMigrations(this.handle);
}
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
await this.storageAdapter.migrate();
}
async onApplicationShutdown(): Promise<void> {
await Promise.all([this.handle.close(), this.storageAdapter.close()]);
}
@@ -24,10 +24,11 @@
*/
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { GoneException, NotFoundException } from '@nestjs/common';
import type { Db } from '@mosaicstack/db';
import { EnrollmentService } from '../enrollment.service.js';
import { makeSelfSignedCert } from './helpers/test-cert.js';
// ---------------------------------------------------------------------------
// Test constants
@@ -38,10 +39,18 @@ const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
const TOKEN = 'a'.repeat(64); // 64-char hex
const MOCK_CERT_PEM = '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n';
const MOCK_CHAIN_PEM = MOCK_CERT_PEM + MOCK_CERT_PEM;
// Real self-signed EC P-256 cert — populated once in beforeAll.
// Required because EnrollmentService.extractCertNotAfter calls new X509Certificate(certPem)
// with strict parsing (PR #501 HIGH-2: no silent fallback).
let REAL_CERT_PEM: string;
const MOCK_CHAIN_PEM = () => REAL_CERT_PEM + REAL_CERT_PEM;
const MOCK_SERIAL = 'ABCD1234';
beforeAll(async () => {
REAL_CERT_PEM = await makeSelfSignedCert();
});
// ---------------------------------------------------------------------------
// Factory helpers
// ---------------------------------------------------------------------------
@@ -103,11 +112,27 @@ function makeDb({
const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock });
// transaction(cb) — cb receives txMock; txMock has update + insert
const txInsertValues = vi.fn().mockResolvedValue(undefined);
const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues });
const txWhereUpdate = vi.fn().mockResolvedValue(undefined);
//
// The tx mock must support two tx.update() call patterns (CRIT-2, PR #501):
// 1. Grant activation: .update().set().where().returning() → resolves to [{ id }]
// 2. Peer update: .update().set().where() → resolves to undefined
//
// We achieve this by making txWhereUpdate return an object with BOTH a thenable
// interface (so `await tx.update().set().where()` works) AND a .returning() method.
const txGrantActivatedRow = { id: GRANT_ID };
const txReturningMock = vi.fn().mockResolvedValue([txGrantActivatedRow]);
const txWhereUpdate = vi.fn().mockReturnValue({
// .returning() for grant activation (first tx.update call)
returning: txReturningMock,
// thenables so `await tx.update().set().where()` also works for peer update
then: (resolve: (v: undefined) => void) => resolve(undefined),
catch: () => undefined,
finally: () => undefined,
});
const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate });
const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock });
const txInsertValues = vi.fn().mockResolvedValue(undefined);
const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues });
const txMock = { update: txUpdateMock, insert: txInsertMock };
const transactionMock = vi
.fn()
@@ -132,6 +157,7 @@ function makeDb({
txInsertValues,
txInsertMock,
txWhereUpdate,
txReturningMock,
txSetMock,
txUpdateMock,
txMock,
@@ -146,11 +172,13 @@ function makeDb({
function makeCaService() {
return {
issueCert: vi.fn().mockResolvedValue({
certPem: MOCK_CERT_PEM,
certChainPem: MOCK_CHAIN_PEM,
// REAL_CERT_PEM is populated by beforeAll — safe to reference via closure here
// because makeCaService() is only called after the suite's beforeAll runs.
issueCert: vi.fn().mockImplementation(async () => ({
certPem: REAL_CERT_PEM,
certChainPem: MOCK_CHAIN_PEM(),
serialNumber: MOCK_SERIAL,
}),
})),
};
}
@@ -301,29 +329,29 @@ describe('EnrollmentService.redeem — success path', () => {
});
caService.issueCert.mockImplementation(async () => {
callOrder.push('issueCert');
return { certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, serialNumber: MOCK_SERIAL };
return { certPem: REAL_CERT_PEM, certChainPem: MOCK_CHAIN_PEM(), serialNumber: MOCK_SERIAL };
});
await service.redeem(TOKEN, MOCK_CERT_PEM);
await service.redeem(TOKEN, '---CSR---');
expect(callOrder).toEqual(['claim', 'issueCert']);
});
it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
await service.redeem(TOKEN, '---CSR---');
expect(caService.issueCert).toHaveBeenCalledWith(
expect.objectContaining({
grantId: GRANT_ID,
subjectUserId: USER_ID,
csrPem: MOCK_CERT_PEM,
csrPem: '---CSR---',
ttlSeconds: 300,
}),
);
});
it('runs activate grant + peer update + audit inside a transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
await service.redeem(TOKEN, '---CSR---');
expect(db._mocks.transactionMock).toHaveBeenCalledOnce();
// tx.update called twice: activate grant + update peer
@@ -333,17 +361,17 @@ describe('EnrollmentService.redeem — success path', () => {
});
it('activates grant (sets status=active) inside the transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
await service.redeem(TOKEN, '---CSR---');
expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }));
});
it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
await service.redeem(TOKEN, '---CSR---');
expect(db._mocks.txSetMock).toHaveBeenCalledWith(
expect.objectContaining({
certPem: MOCK_CERT_PEM,
certPem: REAL_CERT_PEM,
certSerial: MOCK_SERIAL,
state: 'active',
}),
@@ -351,7 +379,7 @@ describe('EnrollmentService.redeem — success path', () => {
});
it('inserts an audit log row inside the transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
await service.redeem(TOKEN, '---CSR---');
expect(db._mocks.txInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
@@ -363,11 +391,11 @@ describe('EnrollmentService.redeem — success path', () => {
});
it('returns { certPem, certChainPem } from CaService', async () => {
const result = await service.redeem(TOKEN, MOCK_CERT_PEM);
const result = await service.redeem(TOKEN, '---CSR---');
expect(result).toEqual({
certPem: MOCK_CERT_PEM,
certChainPem: MOCK_CHAIN_PEM,
certPem: REAL_CERT_PEM,
certChainPem: MOCK_CHAIN_PEM(),
});
});
});
@@ -0,0 +1,138 @@
/**
* Test helpers for generating real X.509 PEM certificates in unit tests.
*
* PR #501 (FED-M2-11) introduced strict `new X509Certificate(certPem)` parsing
* in both EnrollmentService.extractCertNotAfter and CaService.issueCert — dummy
* cert strings now throw `error:0680007B:asn1 encoding routines::header too long`.
*
* These helpers produce minimal but cryptographically valid self-signed EC P-256
* certificates via @peculiar/x509 + Node.js webcrypto, suitable for test mocks.
*
* Two variants:
* - makeSelfSignedCert() Plain cert — satisfies node:crypto X509Certificate parse.
* - makeMosaicIssuedCert(opts) Cert with custom Mosaic OID extensions — satisfies the
* CRIT-1 OID presence + value checks in CaService.issueCert.
*/
import { webcrypto } from 'node:crypto';
import {
X509CertificateGenerator,
Extension,
KeyUsagesExtension,
KeyUsageFlags,
BasicConstraintsExtension,
cryptoProvider,
} from '@peculiar/x509';
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Encode a string as an ASN.1 UTF8String TLV:
* 0x0C (tag) + 1-byte length (for strings ≤ 127 bytes) + UTF-8 bytes.
*
* CaService.issueCert reads the extension value as:
* decoder.decode(grantIdExt.value.slice(2))
* i.e. it skips the tag + length byte and decodes the remainder as UTF-8.
* So we must produce exactly this encoding as the OCTET STRING content.
*/
function encodeUtf8String(value: string): Uint8Array {
const utf8 = new TextEncoder().encode(value);
if (utf8.length > 127) {
throw new Error('encodeUtf8String: value too long for single-byte length encoding');
}
const buf = new Uint8Array(2 + utf8.length);
buf[0] = 0x0c; // ASN.1 UTF8String tag
buf[1] = utf8.length;
buf.set(utf8, 2);
return buf;
}
// ---------------------------------------------------------------------------
// Mosaic OID constants (must match production CaService)
// ---------------------------------------------------------------------------
const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1';
const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2';
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Generate a minimal self-signed EC P-256 certificate valid for 1 day.
* CN=harness-test, no custom extensions.
*
* Suitable for:
* - EnrollmentService.extractCertNotAfter (just needs parseable PEM)
* - Any mock that returns certPem / certChainPem without OID checks
*/
export async function makeSelfSignedCert(): Promise<string> {
// Ensure @peculiar/x509 uses Node.js webcrypto (available as globalThis.crypto in Node 19+,
// but we set it explicitly here to be safe on all Node 18+ versions).
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
const now = new Date();
const tomorrow = new Date(now.getTime() + 86_400_000);
const cert = await X509CertificateGenerator.createSelfSigned({
serialNumber: '01',
name: 'CN=harness-test',
notBefore: now,
notAfter: tomorrow,
signingAlgorithm: alg,
keys,
extensions: [
new BasicConstraintsExtension(false),
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
],
});
return cert.toString('pem');
}
/**
* Generate a self-signed EC P-256 certificate that contains the two custom
* Mosaic OID extensions required by CaService.issueCert's CRIT-1 check:
* OID 1.3.6.1.4.1.99999.1 → mosaic_grant_id (value = grantId)
* OID 1.3.6.1.4.1.99999.2 → mosaic_subject_user_id (value = subjectUserId)
*
* The extension value encoding matches the production parser's `.slice(2)` assumption:
* each extension value is an OCTET STRING wrapping an ASN.1 UTF8String TLV.
*/
export async function makeMosaicIssuedCert(opts: {
grantId: string;
subjectUserId: string;
}): Promise<string> {
// Ensure @peculiar/x509 uses Node.js webcrypto.
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
const now = new Date();
const tomorrow = new Date(now.getTime() + 86_400_000);
const cert = await X509CertificateGenerator.createSelfSigned({
serialNumber: '01',
name: 'CN=mosaic-issued-test',
notBefore: now,
notAfter: tomorrow,
signingAlgorithm: alg,
keys,
extensions: [
new BasicConstraintsExtension(false),
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
// mosaic_grant_id — OID 1.3.6.1.4.1.99999.1
new Extension(OID_MOSAIC_GRANT_ID, false, encodeUtf8String(opts.grantId)),
// mosaic_subject_user_id — OID 1.3.6.1.4.1.99999.2
new Extension(OID_MOSAIC_SUBJECT_USER_ID, false, encodeUtf8String(opts.subjectUserId)),
],
});
return cert.toString('pem');
}
+30 -15
View File
@@ -20,9 +20,10 @@
*/
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, beforeAll, type Mock } from 'vitest';
import { jwtVerify, exportJWK, generateKeyPair } from 'jose';
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
import { makeMosaicIssuedCert } from './__tests__/helpers/test-cert.js';
// ---------------------------------------------------------------------------
// Mock node:https BEFORE importing CaService so the mock is in place when
@@ -74,6 +75,11 @@ const FAKE_CA_PEM = FAKE_CERT_PEM;
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22';
// Real self-signed cert containing both Mosaic OID extensions — populated in beforeAll.
// Required because CaService.issueCert performs CRIT-1 OID presence/value checks on the
// response cert (PR #501 — strict parsing, no silent fallback).
let realIssuedCertPem: string;
// ---------------------------------------------------------------------------
// Generate a real EC P-256 key pair and CSR for integration-style tests
// ---------------------------------------------------------------------------
@@ -194,6 +200,15 @@ function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): vo
describe('CaService', () => {
let service: CaService;
beforeAll(async () => {
// Generate a cert with the two Mosaic OIDs so that CaService.issueCert's
// CRIT-1 OID checks pass when mock step-ca returns it as `crt`.
realIssuedCertPem = await makeMosaicIssuedCert({
grantId: GRANT_ID,
subjectUserId: SUBJECT_USER_ID,
});
});
beforeEach(() => {
vi.clearAllMocks();
service = new CaService();
@@ -226,9 +241,9 @@ describe('CaService', () => {
// Now test that the service's validateCsr accepts it.
// We call it indirectly via issueCert with a successful mock.
makeHttpsMock(200, { crt: FAKE_CERT_PEM, certChain: [FAKE_CERT_PEM, FAKE_CA_PEM] });
makeHttpsMock(200, { crt: realIssuedCertPem, certChain: [realIssuedCertPem, FAKE_CA_PEM] });
const result = await service.issueCert(makeReq({ csrPem: realCsrPem }));
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certPem).toBe(realIssuedCertPem);
});
it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => {
@@ -251,14 +266,14 @@ describe('CaService', () => {
it('returns IssuedCertDto on success (certChain present)', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, {
crt: FAKE_CERT_PEM,
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM],
crt: realIssuedCertPem,
certChain: [realIssuedCertPem, FAKE_CA_PEM],
});
const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
expect(result.certPem).toBe(realIssuedCertPem);
expect(result.certChainPem).toContain(realIssuedCertPem);
expect(result.certChainPem).toContain(FAKE_CA_PEM);
expect(typeof result.serialNumber).toBe('string');
});
@@ -270,14 +285,14 @@ describe('CaService', () => {
it('builds certChainPem from crt+ca when certChain is absent', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, {
crt: FAKE_CERT_PEM,
crt: realIssuedCertPem,
ca: FAKE_CA_PEM,
});
const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
expect(result.certPem).toBe(realIssuedCertPem);
expect(result.certChainPem).toContain(realIssuedCertPem);
expect(result.certChainPem).toContain(FAKE_CA_PEM);
});
@@ -287,12 +302,12 @@ describe('CaService', () => {
it('falls back to certPem alone when certChain and ca are absent', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, { crt: FAKE_CERT_PEM });
makeHttpsMock(200, { crt: realIssuedCertPem });
const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certChainPem).toBe(FAKE_CERT_PEM);
expect(result.certPem).toBe(realIssuedCertPem);
expect(result.certChainPem).toBe(realIssuedCertPem);
});
// -------------------------------------------------------------------------
@@ -398,7 +413,7 @@ describe('CaService', () => {
statusCode: 200,
on: (event: string, cb: (chunk?: Buffer) => void) => {
if (event === 'data') {
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
cb(Buffer.from(JSON.stringify({ crt: realIssuedCertPem })));
}
if (event === 'end') {
cb();
@@ -555,7 +570,7 @@ describe('CaService', () => {
statusCode: 200,
on: (event: string, cb: (chunk?: Buffer) => void) => {
if (event === 'data') {
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
cb(Buffer.from(JSON.stringify({ crt: realIssuedCertPem })));
}
if (event === 'end') {
cb();
@@ -0,0 +1,553 @@
/**
* Unit tests for FederationClientService (FED-M3-08).
*
* HTTP mocking strategy:
* undici MockAgent is used to intercept outbound HTTP requests. The service
* uses `undici.fetch` with a `dispatcher` option, so MockAgent is set as the
* global dispatcher and all requests flow through it.
*
* Because the service builds one `undici.Agent` per peer and passes it as
* the dispatcher on every fetch call, we cannot intercept at the Agent level
* in unit tests without significant refactoring. Instead, we set the global
* dispatcher to a MockAgent and override the service's `doRequest` indirection
* by spying on the internal fetch call.
*
* For the cert/key wiring, we use the real `sealClientKey` function from
* peer-key.util.ts with a test secret — no stubs.
*
* Sealed-key setup:
* Each test (or beforeAll) calls `sealClientKey(TEST_PRIVATE_KEY_PEM)` with
* BETTER_AUTH_SECRET set to a deterministic test value so that
* `unsealClientKey` in the service recovers the original PEM.
*/
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
import type { Dispatcher } from 'undici';
import { writeFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Db } from '@mosaicstack/db';
import { FederationClientService, FederationClientError } from '../federation-client.service.js';
import { sealClientKey } from '../../peer-key.util.js';
// ---------------------------------------------------------------------------
// Test constants
// ---------------------------------------------------------------------------
const TEST_SECRET = 'test-secret-for-federation-client-spec-only';
const PEER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const ENDPOINT = 'https://peer.example.com';
// Minimal valid RSA/EC private key PEM — does NOT need to be a real key for
// unit tests because we only verify it round-trips through seal/unseal, not
// that it actually negotiates TLS (MockAgent handles that).
const TEST_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDummyKeyForTests
-----END PRIVATE KEY-----`;
// Minimal self-signed cert PEM (dummy — only used for mTLS Agent construction)
const TEST_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIBdummyCertForFederationClientTests==
-----END CERTIFICATE-----`;
const TEST_CERT_SERIAL = 'ABCDEF1234567890';
// ---------------------------------------------------------------------------
// Sealed key (computed once in beforeAll)
// ---------------------------------------------------------------------------
let SEALED_KEY: string;
// Path to a stub Step-CA root cert file written in beforeAll. The cert is never
// actually used to negotiate TLS in unit tests (MockAgent + spy on resolveEntry
// short-circuit the network), but loadStepCaRoot() requires the file to exist.
const STUB_CA_PEM_PATH = join(tmpdir(), 'federation-client-spec-ca.pem');
const STUB_CA_PEM = `-----BEGIN CERTIFICATE-----
MIIBdummyCAforFederationClientSpecOnly==
-----END CERTIFICATE-----
`;
// ---------------------------------------------------------------------------
// Peer row factory
// ---------------------------------------------------------------------------
function makePeerRow(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: PEER_ID,
commonName: 'peer-example-com',
displayName: 'Test Peer',
certPem: TEST_CERT_PEM,
certSerial: TEST_CERT_SERIAL,
certNotAfter: new Date('2030-01-01T00:00:00Z'),
clientKeyPem: SEALED_KEY,
state: 'active' as const,
endpointUrl: ENDPOINT,
lastSeenAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
revokedAt: null,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Mock DB builder
// ---------------------------------------------------------------------------
function makeDb(selectRows: unknown[] = [makePeerRow()]): Db {
const limitSelect = vi.fn().mockResolvedValue(selectRows);
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
return {
select: selectMock,
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
} as unknown as Db;
}
// ---------------------------------------------------------------------------
// Helpers for MockAgent HTTP interception
// ---------------------------------------------------------------------------
/**
* Create a MockAgent + MockPool for the peer endpoint, set it as the global
* dispatcher, and return both for per-test configuration.
*/
function makeMockAgent() {
const mockAgent = new MockAgent({ connections: 1 });
mockAgent.disableNetConnect();
setGlobalDispatcher(mockAgent);
const pool = mockAgent.get(ENDPOINT);
return { mockAgent, pool };
}
/**
* Build a FederationClientService with a mock DB and a spy on the internal
* fetch so we can intercept at the HTTP layer via MockAgent.
*
* The service calls `fetch(url, { dispatcher: agent })` where `agent` is the
* mTLS undici.Agent built from the peer's cert+key. To make MockAgent work,
* we need the fetch dispatcher to be the MockAgent, not the per-peer Agent.
*
* Strategy: we replace the private `resolveEntry` result's `agent` field with
* the MockAgent's pool, so fetch uses our interceptor. We do this by spying
* on `resolveEntry` and returning a controlled entry.
*/
function makeService(db: Db, mockPool: Dispatcher): FederationClientService {
const svc = new FederationClientService(db);
// Override resolveEntry to inject MockAgent pool as the dispatcher
vi.spyOn(
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
'resolveEntry',
).mockImplementation(async (_peerId: string) => {
// Still call DB (via the real logic) to exercise peer validation,
// but return mock pool as the agent.
// For simplicity in unit tests, directly return a controlled entry.
return {
agent: mockPool,
endpointUrl: ENDPOINT,
certPem: TEST_CERT_PEM,
certSerial: TEST_CERT_SERIAL,
};
});
return svc;
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
let originalDispatcher: Dispatcher;
beforeAll(() => {
// Seal the test key once — requires BETTER_AUTH_SECRET
const saved = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
try {
SEALED_KEY = sealClientKey(TEST_PRIVATE_KEY_PEM);
} finally {
if (saved === undefined) {
delete process.env['BETTER_AUTH_SECRET'];
} else {
process.env['BETTER_AUTH_SECRET'] = saved;
}
}
writeFileSync(STUB_CA_PEM_PATH, STUB_CA_PEM, 'utf8');
});
afterAll(() => {
try {
unlinkSync(STUB_CA_PEM_PATH);
} catch {
// best-effort cleanup
}
});
beforeEach(() => {
originalDispatcher = getGlobalDispatcher();
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
process.env['STEP_CA_ROOT_CERT_PATH'] = STUB_CA_PEM_PATH;
});
afterEach(() => {
setGlobalDispatcher(originalDispatcher);
vi.restoreAllMocks();
delete process.env['BETTER_AUTH_SECRET'];
delete process.env['STEP_CA_ROOT_CERT_PATH'];
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Successful list response body */
const LIST_BODY = {
items: [{ id: '1', title: 'Task One' }],
nextCursor: undefined,
_partial: false,
};
/** Successful get response body */
const GET_BODY = {
item: { id: '1', title: 'Task One' },
_partial: false,
};
/** Successful capabilities response body */
const CAP_BODY = {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 100,
supported_verbs: ['list', 'get', 'capabilities'] as const,
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FederationClientService', () => {
// ─── Successful verb calls ─────────────────────────────────────────────────
describe('list()', () => {
it('returns parsed typed response on success', async () => {
const db = makeDb();
const { mockAgent, pool } = makeMockAgent();
const svc = makeService(db, pool);
pool
.intercept({
path: '/api/federation/v1/list/tasks',
method: 'POST',
})
.reply(200, LIST_BODY, { headers: { 'content-type': 'application/json' } });
const result = await svc.list(PEER_ID, 'tasks', {});
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({ id: '1', title: 'Task One' });
await mockAgent.close();
});
});
describe('get()', () => {
it('returns parsed typed response on success', async () => {
const db = makeDb();
const { mockAgent, pool } = makeMockAgent();
const svc = makeService(db, pool);
pool
.intercept({
path: '/api/federation/v1/get/tasks/1',
method: 'POST',
})
.reply(200, GET_BODY, { headers: { 'content-type': 'application/json' } });
const result = await svc.get(PEER_ID, 'tasks', '1', {});
expect(result.item).toMatchObject({ id: '1', title: 'Task One' });
await mockAgent.close();
});
});
describe('capabilities()', () => {
it('returns parsed capabilities response on success', async () => {
const db = makeDb();
const { mockAgent, pool } = makeMockAgent();
const svc = makeService(db, pool);
pool
.intercept({
path: '/api/federation/v1/capabilities',
method: 'GET',
})
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } });
const result = await svc.capabilities(PEER_ID);
expect(result.resources).toContain('tasks');
expect(result.max_rows_per_query).toBe(100);
await mockAgent.close();
});
});
// ─── HTTP error surfaces ──────────────────────────────────────────────────
describe('non-2xx responses', () => {
it('surfaces 403 as FederationClientError({ status: 403, code: "FORBIDDEN" })', async () => {
const db = makeDb();
const { mockAgent, pool } = makeMockAgent();
const svc = makeService(db, pool);
pool.intercept({ path: '/api/federation/v1/list/tasks', method: 'POST' }).reply(
403,
{ error: { code: 'forbidden', message: 'Access denied' } },
{
headers: { 'content-type': 'application/json' },
},
);
await expect(svc.list(PEER_ID, 'tasks', {})).rejects.toMatchObject({
status: 403,
code: 'FORBIDDEN',
peerId: PEER_ID,
});
await mockAgent.close();
});
it('surfaces 404 as FederationClientError({ status: 404, code: "HTTP_404" })', async () => {
const db = makeDb();
const { mockAgent, pool } = makeMockAgent();
const svc = makeService(db, pool);
pool.intercept({ path: '/api/federation/v1/get/tasks/999', method: 'POST' }).reply(
404,
{ error: { code: 'not_found', message: 'Not found' } },
{
headers: { 'content-type': 'application/json' },
},
);
await expect(svc.get(PEER_ID, 'tasks', '999', {})).rejects.toMatchObject({
status: 404,
code: 'HTTP_404',
peerId: PEER_ID,
});
await mockAgent.close();
});
});
// ─── Network error ─────────────────────────────────────────────────────────
describe('network errors', () => {
it('surfaces network error as FederationClientError({ code: "NETWORK" })', async () => {
const db = makeDb();
const { mockAgent, pool } = makeMockAgent();
const svc = makeService(db, pool);
pool
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
.replyWithError(new Error('ECONNREFUSED'));
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
code: 'NETWORK',
peerId: PEER_ID,
});
await mockAgent.close();
});
});
// ─── Invalid response body ─────────────────────────────────────────────────
describe('invalid response body', () => {
it('surfaces as FederationClientError({ code: "INVALID_RESPONSE" }) when body shape is wrong', async () => {
const db = makeDb();
const { mockAgent, pool } = makeMockAgent();
const svc = makeService(db, pool);
// capabilities returns wrong shape (missing required fields)
pool
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
.reply(200, { totally: 'wrong' }, { headers: { 'content-type': 'application/json' } });
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
code: 'INVALID_RESPONSE',
peerId: PEER_ID,
});
await mockAgent.close();
});
});
// ─── Peer DB validation ────────────────────────────────────────────────────
describe('peer validation (without resolveEntry spy)', () => {
/**
* These tests exercise the real `resolveEntry` path — no spy on resolveEntry.
*/
it('throws PEER_NOT_FOUND when peer is not in DB', async () => {
// DB returns empty array (peer not found)
const db = makeDb([]);
const svc = new FederationClientService(db);
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
code: 'PEER_NOT_FOUND',
peerId: PEER_ID,
});
});
it('throws PEER_INACTIVE when peer state is not "active"', async () => {
const db = makeDb([makePeerRow({ state: 'suspended' })]);
const svc = new FederationClientService(db);
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
code: 'PEER_INACTIVE',
peerId: PEER_ID,
});
});
});
// ─── Cache behaviour ───────────────────────────────────────────────────────
describe('cache behaviour', () => {
it('hits cache on second call — only one DB lookup happens', async () => {
// Verify cache by calling the private resolveEntry directly twice and
// asserting the DB was queried only once. This avoids the HTTP layer,
// which would require either a real network or per-peer Agent rewiring
// that the cache invariant doesn't depend on.
const db = makeDb();
const selectSpy = vi.spyOn(db, 'select');
const svc = new FederationClientService(db);
const resolveEntry = (
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> }
).resolveEntry.bind(svc);
const first = await resolveEntry(PEER_ID);
const second = await resolveEntry(PEER_ID);
expect(first).toBe(second);
expect(selectSpy).toHaveBeenCalledTimes(1);
});
it('serializes concurrent resolveEntry calls — only one DB lookup', async () => {
const db = makeDb();
const selectSpy = vi.spyOn(db, 'select');
const svc = new FederationClientService(db);
const resolveEntry = (
svc as unknown as {
resolveEntry: (peerId: string) => Promise<unknown>;
}
).resolveEntry.bind(svc);
const [a, b] = await Promise.all([resolveEntry(PEER_ID), resolveEntry(PEER_ID)]);
expect(a).toBe(b);
expect(selectSpy).toHaveBeenCalledTimes(1);
});
it('flushPeer destroys the evicted Agent so old TLS connections close', async () => {
const db = makeDb();
const svc = new FederationClientService(db);
const resolveEntry = (
svc as unknown as {
resolveEntry: (peerId: string) => Promise<{ agent: { destroy: () => Promise<void> } }>;
}
).resolveEntry.bind(svc);
const entry = await resolveEntry(PEER_ID);
const destroySpy = vi.spyOn(entry.agent, 'destroy').mockResolvedValue();
svc.flushPeer(PEER_ID);
expect(destroySpy).toHaveBeenCalledTimes(1);
});
it('flushPeer() invalidates cache — next call re-reads DB', async () => {
const db = makeDb();
const { mockAgent, pool } = makeMockAgent();
const svc = makeService(db, pool);
pool
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } })
.times(2);
// First call — populates cache (via mock resolveEntry)
await svc.capabilities(PEER_ID);
// Flush the cache
svc.flushPeer(PEER_ID);
// The spy on resolveEntry is still active — check it's called again after flush
const resolveEntrySpy = vi.spyOn(
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
'resolveEntry',
);
// Second call after flush — should call resolveEntry again
await svc.capabilities(PEER_ID);
// resolveEntry should have been called once after we started spying (post-flush)
expect(resolveEntrySpy).toHaveBeenCalledTimes(1);
await mockAgent.close();
});
});
// ─── loadStepCaRoot env-var guard ─────────────────────────────────────────
describe('loadStepCaRoot() env-var guard', () => {
it('throws PEER_MISCONFIGURED when STEP_CA_ROOT_CERT_PATH is not set', async () => {
delete process.env['STEP_CA_ROOT_CERT_PATH'];
const db = makeDb();
const svc = new FederationClientService(db);
const resolveEntry = (
svc as unknown as {
resolveEntry: (peerId: string) => Promise<unknown>;
}
).resolveEntry.bind(svc);
await expect(resolveEntry(PEER_ID)).rejects.toMatchObject({
code: 'PEER_MISCONFIGURED',
});
});
});
// ─── FederationClientError class ──────────────────────────────────────────
describe('FederationClientError', () => {
it('is instanceof Error and FederationClientError', () => {
const err = new FederationClientError({
code: 'PEER_NOT_FOUND',
message: 'test',
peerId: PEER_ID,
});
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(FederationClientError);
expect(err.name).toBe('FederationClientError');
});
it('carries status, code, and peerId', () => {
const err = new FederationClientError({
status: 403,
code: 'FORBIDDEN',
message: 'forbidden',
peerId: PEER_ID,
});
expect(err.status).toBe(403);
expect(err.code).toBe('FORBIDDEN');
expect(err.peerId).toBe(PEER_ID);
});
});
});
@@ -0,0 +1,255 @@
import 'reflect-metadata';
import { describe, expect, it, vi } from 'vitest';
import type { Db } from '@mosaicstack/db';
import type { FederationListResponse } from '@mosaicstack/types';
import {
FederationClientError,
type FederationClientService,
} from '../federation-client.service.js';
import { type QuerySourceError, QuerySourceService } from '../query-source.service.js';
interface TestRow {
id: string;
title: string;
}
interface PeerRow {
id: string;
commonName: string;
endpointUrl: string | null;
clientKeyPem: string | null;
state: 'active' | 'pending' | 'suspended' | 'revoked';
}
const LOCAL_ROWS: TestRow[] = [
{ id: 'local-1', title: 'Local One' },
{ id: 'local-2', title: 'Local Two' },
];
const PEER_A: PeerRow = {
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
commonName: 'peer-a',
endpointUrl: 'https://peer-a.example.com',
clientKeyPem: 'sealed-key-a',
state: 'active',
};
const PEER_B: PeerRow = {
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
commonName: 'peer-b',
endpointUrl: 'https://peer-b.example.com',
clientKeyPem: 'sealed-key-b',
state: 'active',
};
const PEER_LOCALHOST: PeerRow = {
id: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
commonName: 'peer-localhost',
endpointUrl: 'https://localhost:3001',
clientKeyPem: 'sealed-key-c',
state: 'active',
};
function makeDb(activePeers: PeerRow[]): Db {
const orderBy = vi.fn().mockResolvedValue(activePeers);
const where = vi.fn().mockReturnValue({ orderBy });
const from = vi.fn().mockReturnValue({ where });
const select = vi.fn().mockReturnValue({ from });
return {
select,
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
} as unknown as Db;
}
function makeFederationClient(
list: (
peerId: string,
resource: string,
request: Record<string, unknown>,
) => Promise<FederationListResponse<TestRow>>,
): FederationClientService {
return {
list: list as unknown as FederationClientService['list'],
} as FederationClientService;
}
function makeLocalResponse(rows: TestRow[] = LOCAL_ROWS): Promise<FederationListResponse<TestRow>> {
return Promise.resolve({ items: rows });
}
describe('QuerySourceService', () => {
it('routes source="local" to the local executor and tags rows as local', async () => {
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'local',
resource: 'tasks',
request: { cursor: 'ignored-for-local-test' },
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'local-2', title: 'Local Two', _source: 'local' },
],
});
expect(list).not.toHaveBeenCalled();
});
it('routes source="federated:<host>" to the matching active peer and tags rows with peer commonName', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-1', title: 'Remote One' }],
}),
);
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'federated:peer-b.example.com',
resource: 'tasks',
request: { status: 'open' },
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [{ id: 'remote-1', title: 'Remote One', _source: 'peer-b' }],
});
expect(list).toHaveBeenCalledWith(PEER_B.id, 'tasks', { status: 'open' });
});
it('matches federated hosts by endpoint host including non-default port', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-port', title: 'Remote Port' }],
}),
);
const service = new QuerySourceService(makeDb([PEER_LOCALHOST]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'federated:localhost:3001',
resource: 'tasks',
request: {},
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [{ id: 'remote-port', title: 'Remote Port', _source: 'peer-localhost' }],
});
expect(list).toHaveBeenCalledWith(PEER_LOCALHOST.id, 'tasks', {});
});
it('fans out source="all" to local plus every active outbound peer in parallel and merges tagged rows', async () => {
const callOrder: string[] = [];
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
callOrder.push(`remote-start:${peerId}`);
await Promise.resolve();
return {
items: [{ id: `remote-${peerId.slice(0, 1)}`, title: `Remote ${peerId.slice(0, 1)}` }],
};
});
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: { limit: 25 },
local: async () => {
callOrder.push('local-start');
await Promise.resolve();
return { items: [{ id: 'local-1', title: 'Local One' }] };
},
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote a', _source: 'peer-a' },
{ id: 'remote-b', title: 'Remote b', _source: 'peer-b' },
],
});
expect(list).toHaveBeenCalledTimes(2);
expect(callOrder).toEqual([
'local-start',
`remote-start:${PEER_A.id}`,
`remote-start:${PEER_B.id}`,
]);
});
it('marks source="all" as partial and truncated when any subquery returns a cursor', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-a', title: 'Remote A' }],
nextCursor: 'remote-next',
}),
);
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: {},
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
],
_partial: true,
_truncated: true,
});
});
it('returns _partial=true for source="all" when one peer fails without dropping successful sources', async () => {
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
if (peerId === PEER_B.id) {
throw new FederationClientError({
code: 'NETWORK',
message: 'peer unavailable',
peerId,
});
}
return { items: [{ id: 'remote-a', title: 'Remote A' }] };
});
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: {},
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
],
_partial: true,
});
});
it('throws QuerySourceError when a federated host does not match an active outbound peer', async () => {
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
await expect(
service.list<TestRow>({
source: 'federated:missing.example.com',
resource: 'tasks',
request: {},
local: () => makeLocalResponse(),
}),
).rejects.toMatchObject({
name: 'QuerySourceError',
code: 'PEER_NOT_FOUND',
} satisfies Partial<QuerySourceError>);
});
});
@@ -0,0 +1,500 @@
/**
* FederationClientService — outbound mTLS client for federation requests (FED-M3-08).
*
* Dials peer gateways over mTLS using the cert+sealed-key stored in `federation_peers`,
* invokes federation verbs (list / get / capabilities), and surfaces all failure modes
* as typed `FederationClientError` instances.
*
* ## Error code taxonomy
*
* | Code | When |
* | ------------------ | ------------------------------------------------------------- |
* | PEER_NOT_FOUND | No row in federation_peers for the given peerId |
* | PEER_INACTIVE | Peer row exists but state !== 'active' |
* | PEER_MISCONFIGURED | Peer row is active but missing endpointUrl or clientKeyPem |
* | NETWORK | undici threw a connection / TLS / timeout error |
* | HTTP_{status} | Peer returned a non-2xx response (e.g. HTTP_403, HTTP_404) |
* | FORBIDDEN | Peer returned 403 (convenience alias alongside HTTP_403) |
* | INVALID_RESPONSE | Response body failed Zod schema validation |
*
* ## Cache strategy
*
* Per-peer `undici.Agent` instances are cached in a `Map<peerId, AgentCacheEntry>` for
* the lifetime of the service instance. The cache is keyed on peerId (UUID).
*
* Cache invalidation:
* - `flushPeer(peerId)` — removes the entry immediately. M5/M6 MUST call this on
* cert rotation or peer revocation events so the next request re-reads the DB and
* builds a fresh TLS Agent with the new cert material.
* - On cache miss: re-reads the DB, checks state === 'active', rebuilds Agent.
*
* Cache does NOT auto-expire. The service is expected to be a singleton scoped to the
* NestJS application lifecycle; flushing on revocation/rotation is the only invalidation
* path by design (avoids redundant DB round-trips on the hot path).
*/
import { Injectable, Inject, Logger } from '@nestjs/common';
import { readFileSync } from 'node:fs';
import { Agent, fetch as undiciFetch } from 'undici';
import type { Dispatcher } from 'undici';
import { z } from 'zod';
import { type Db, eq, federationPeers } from '@mosaicstack/db';
import {
FederationListResponseSchema,
FederationGetResponseSchema,
FederationCapabilitiesResponseSchema,
FederationErrorEnvelopeSchema,
type FederationListResponse,
type FederationGetResponse,
type FederationCapabilitiesResponse,
} from '@mosaicstack/types';
import { DB } from '../../database/database.module.js';
import { unsealClientKey } from '../peer-key.util.js';
// ---------------------------------------------------------------------------
// Error taxonomy
// ---------------------------------------------------------------------------
/**
* Client-side error code set. Distinct from the server-side `FederationErrorCode`
* (which lives in `@mosaicstack/types`) because the client has additional failure
* modes (PEER_NOT_FOUND, PEER_INACTIVE, PEER_MISCONFIGURED, NETWORK) that the
* server never emits.
*/
export type FederationClientErrorCode =
| 'PEER_NOT_FOUND'
| 'PEER_INACTIVE'
| 'PEER_MISCONFIGURED'
| 'NETWORK'
| 'FORBIDDEN'
| 'INVALID_RESPONSE'
| `HTTP_${number}`;
export interface FederationClientErrorOptions {
status?: number;
code: FederationClientErrorCode;
message: string;
peerId: string;
cause?: unknown;
}
/**
* Thrown by FederationClientService on every failure path.
* Callers can dispatch on `error.code` for programmatic handling.
*/
export class FederationClientError extends Error {
readonly status?: number;
readonly code: FederationClientErrorCode;
readonly peerId: string;
readonly cause?: unknown;
constructor(opts: FederationClientErrorOptions) {
super(opts.message);
this.name = 'FederationClientError';
this.status = opts.status;
this.code = opts.code;
this.peerId = opts.peerId;
this.cause = opts.cause;
}
}
// ---------------------------------------------------------------------------
// Internal cache types
// ---------------------------------------------------------------------------
interface AgentCacheEntry {
agent: Agent;
endpointUrl: string;
certPem: string;
certSerial: string;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@Injectable()
export class FederationClientService {
private readonly logger = new Logger(FederationClientService.name);
/**
* Per-peer undici Agent cache.
* Key = peerId (UUID string).
*
* Values are either a resolved `AgentCacheEntry` or an in-flight
* `Promise<AgentCacheEntry>` (promise-cache pattern). Storing the promise
* prevents duplicate DB lookups and duplicate key-unseal operations when two
* requests for the same peer arrive before the first build completes.
*
* Flush via `flushPeer(peerId)` on cert rotation / peer revocation (M5/M6).
*/
private readonly cache = new Map<string, AgentCacheEntry | Promise<AgentCacheEntry>>();
/**
* Step-CA root cert PEM, loaded once from `STEP_CA_ROOT_CERT_PATH`.
* Used as the trust anchor for peer server certificates so federation TLS is
* pinned to our PKI, not the public trust store. Lazily loaded on first use
* so unit tests that don't exercise the agent path can run without the env var.
*/
private cachedCaPem: string | null = null;
constructor(@Inject(DB) private readonly db: Db) {}
// -------------------------------------------------------------------------
// Public verb API
// -------------------------------------------------------------------------
/**
* Invoke the `list` verb on a remote peer.
*
* @param peerId UUID of the peer row in `federation_peers`.
* @param resource Resource path, e.g. "tasks".
* @param request Free-form body sent as JSON in the POST body.
* @returns Parsed `FederationListResponse<T>`.
*/
async list<T>(
peerId: string,
resource: string,
request: Record<string, unknown>,
): Promise<FederationListResponse<T>> {
const { endpointUrl, agent } = await this.resolveEntry(peerId);
const url = `${endpointUrl}/api/federation/v1/list/${encodeURIComponent(resource)}`;
const body = await this.doPost(peerId, url, agent, request);
return this.parseWith<FederationListResponse<T>>(
peerId,
body,
FederationListResponseSchema(z.unknown()),
);
}
/**
* Invoke the `get` verb on a remote peer.
*
* @param peerId UUID of the peer row in `federation_peers`.
* @param resource Resource path, e.g. "tasks".
* @param id Resource identifier.
* @param request Free-form body sent as JSON in the POST body.
* @returns Parsed `FederationGetResponse<T>`.
*/
async get<T>(
peerId: string,
resource: string,
id: string,
request: Record<string, unknown>,
): Promise<FederationGetResponse<T>> {
const { endpointUrl, agent } = await this.resolveEntry(peerId);
const url = `${endpointUrl}/api/federation/v1/get/${encodeURIComponent(resource)}/${encodeURIComponent(id)}`;
const body = await this.doPost(peerId, url, agent, request);
return this.parseWith<FederationGetResponse<T>>(
peerId,
body,
FederationGetResponseSchema(z.unknown()),
);
}
/**
* Invoke the `capabilities` verb on a remote peer.
*
* @param peerId UUID of the peer row in `federation_peers`.
* @returns Parsed `FederationCapabilitiesResponse`.
*/
async capabilities(peerId: string): Promise<FederationCapabilitiesResponse> {
const { endpointUrl, agent } = await this.resolveEntry(peerId);
const url = `${endpointUrl}/api/federation/v1/capabilities`;
const body = await this.doGet(peerId, url, agent);
return this.parseWith<FederationCapabilitiesResponse>(
peerId,
body,
FederationCapabilitiesResponseSchema,
);
}
// -------------------------------------------------------------------------
// Cache management
// -------------------------------------------------------------------------
/**
* Flush the cached Agent for a specific peer.
*
* M5/M6 MUST call this on:
* - cert rotation events (so new cert material is picked up)
* - peer revocation events (so future requests fail at PEER_INACTIVE)
*
* After flushing, the next call to `list`, `get`, or `capabilities` for
* this peer will re-read the DB and rebuild the Agent.
*/
flushPeer(peerId: string): void {
const entry = this.cache.get(peerId);
if (entry === undefined) {
return;
}
this.cache.delete(peerId);
if (!(entry instanceof Promise)) {
// best-effort destroy; promise-cached entries skip destroy because
// the in-flight build owns its own Agent which will be GC'd when the
// owning request handles the rejection from the cache miss
entry.agent.destroy().catch(() => {
// intentionally ignored — destroy errors are not actionable
});
}
this.logger.log(`Cache flushed for peer ${peerId}`);
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
/**
* Load and cache the Step-CA root cert PEM from `STEP_CA_ROOT_CERT_PATH`.
* Throws `FederationClientError` if the env var is unset or the file cannot
* be read — mTLS to a peer without a pinned trust anchor would silently
* fall back to the public trust store.
*/
private loadStepCaRoot(): string {
if (this.cachedCaPem !== null) {
return this.cachedCaPem;
}
const path = process.env['STEP_CA_ROOT_CERT_PATH'];
if (!path) {
throw new FederationClientError({
code: 'PEER_MISCONFIGURED',
message: 'STEP_CA_ROOT_CERT_PATH is not set; refusing to dial peer without pinned CA trust',
peerId: '',
});
}
try {
const pem = readFileSync(path, 'utf8');
this.cachedCaPem = pem;
return pem;
} catch (err) {
throw new FederationClientError({
code: 'PEER_MISCONFIGURED',
message: `Failed to read STEP_CA_ROOT_CERT_PATH (${path})`,
peerId: '',
cause: err,
});
}
}
/**
* Resolve the cache entry for a peer, reading DB on miss.
*
* Uses a promise-cache pattern: concurrent callers for the same uncached
* `peerId` all `await` the same in-flight `Promise<AgentCacheEntry>` so
* only one DB lookup and one key-unseal ever runs per peer per cache miss.
* The promise is replaced with the concrete entry on success, or deleted on
* rejection so a transient error does not poison the cache permanently.
*
* Throws `FederationClientError` with appropriate code if the peer is not
* found, is inactive, or is missing required fields.
*/
private async resolveEntry(peerId: string): Promise<AgentCacheEntry> {
const cached = this.cache.get(peerId);
if (cached) {
return cached; // Promise or concrete entry — both are awaitable
}
const inflight = this.buildEntry(peerId).then(
(entry) => {
this.cache.set(peerId, entry); // replace promise with concrete value
return entry;
},
(err: unknown) => {
this.cache.delete(peerId); // don't poison the cache with a rejected promise
throw err;
},
);
this.cache.set(peerId, inflight);
return inflight;
}
/**
* Build the `AgentCacheEntry` for a peer by reading the DB, validating the
* peer's state, unsealing the private key, and constructing the mTLS Agent.
*
* Throws `FederationClientError` with appropriate code if the peer is not
* found, is inactive, or is missing required fields.
*/
private async buildEntry(peerId: string): Promise<AgentCacheEntry> {
// DB lookup
const [peer] = await this.db
.select()
.from(federationPeers)
.where(eq(federationPeers.id, peerId))
.limit(1);
if (!peer) {
throw new FederationClientError({
code: 'PEER_NOT_FOUND',
message: `Federation peer ${peerId} not found`,
peerId,
});
}
if (peer.state !== 'active') {
throw new FederationClientError({
code: 'PEER_INACTIVE',
message: `Federation peer ${peerId} is not active (state: ${peer.state})`,
peerId,
});
}
if (!peer.endpointUrl || !peer.clientKeyPem) {
throw new FederationClientError({
code: 'PEER_MISCONFIGURED',
message: `Federation peer ${peerId} is missing endpointUrl or clientKeyPem`,
peerId,
});
}
// Unseal the private key
let privateKeyPem: string;
try {
privateKeyPem = unsealClientKey(peer.clientKeyPem);
} catch (err) {
throw new FederationClientError({
code: 'PEER_MISCONFIGURED',
message: `Failed to unseal client key for peer ${peerId}`,
peerId,
cause: err,
});
}
// Build mTLS agent — pin trust to Step-CA root so we never accept
// a peer cert signed by a public CA (defense against MITM with a
// publicly-trusted DV cert for the peer's hostname).
const agent = new Agent({
connect: {
cert: peer.certPem,
key: privateKeyPem,
ca: this.loadStepCaRoot(),
// rejectUnauthorized: true is the undici default for HTTPS
},
});
const entry: AgentCacheEntry = {
agent,
endpointUrl: peer.endpointUrl,
certPem: peer.certPem,
certSerial: peer.certSerial,
};
this.logger.log(`Agent cached for peer ${peerId} (serial: ${peer.certSerial})`);
return entry;
}
/**
* Execute a POST request with a JSON body.
* Returns the parsed response body as an unknown value.
* Throws `FederationClientError` on network errors and non-2xx responses.
*/
private async doPost(
peerId: string,
url: string,
agent: Dispatcher,
body: Record<string, unknown>,
): Promise<unknown> {
return this.doRequest(peerId, url, agent, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
/**
* Execute a GET request.
* Returns the parsed response body as an unknown value.
* Throws `FederationClientError` on network errors and non-2xx responses.
*/
private async doGet(peerId: string, url: string, agent: Dispatcher): Promise<unknown> {
return this.doRequest(peerId, url, agent, { method: 'GET' });
}
private async doRequest(
peerId: string,
url: string,
agent: Dispatcher,
init: { method: string; headers?: Record<string, string>; body?: string },
): Promise<unknown> {
let response: Awaited<ReturnType<typeof undiciFetch>>;
try {
response = await undiciFetch(url, {
...init,
dispatcher: agent,
});
} catch (err) {
throw new FederationClientError({
code: 'NETWORK',
message: `Network error calling peer ${peerId} at ${url}: ${err instanceof Error ? err.message : String(err)}`,
peerId,
cause: err,
});
}
const rawBody = await response.text().catch(() => '');
if (!response.ok) {
const status = response.status;
// Attempt to parse as federation error envelope
let serverMessage = `HTTP ${status}`;
try {
const json: unknown = JSON.parse(rawBody);
const result = FederationErrorEnvelopeSchema.safeParse(json);
if (result.success) {
serverMessage = result.data.error.message;
}
} catch {
// Not valid JSON or not a federation envelope — use generic message
}
// Specific code for 403 (most actionable for callers); generic HTTP_{n} for others
const code: FederationClientErrorCode = status === 403 ? 'FORBIDDEN' : `HTTP_${status}`;
throw new FederationClientError({
status,
code,
message: `Peer ${peerId} returned ${status}: ${serverMessage}`,
peerId,
});
}
try {
return JSON.parse(rawBody) as unknown;
} catch (err) {
throw new FederationClientError({
code: 'INVALID_RESPONSE',
message: `Peer ${peerId} returned non-JSON body`,
peerId,
cause: err,
});
}
}
/**
* Parse and validate a response body against a Zod schema.
*
* For list/get, callers pass the result of `FederationListResponseSchema(z.unknown())`
* so that the envelope structure is validated without requiring a concrete item schema
* at the client level. The generic `T` provides compile-time typing.
*
* Throws `FederationClientError({ code: 'INVALID_RESPONSE' })` on parse failure.
*/
private parseWith<T>(peerId: string, body: unknown, schema: z.ZodTypeAny): T {
const result = schema.safeParse(body);
if (!result.success) {
const issues = result.error.issues
.map((e: z.ZodIssue) => `[${e.path.join('.') || 'root'}] ${e.message}`)
.join('; ');
throw new FederationClientError({
code: 'INVALID_RESPONSE',
message: `Peer ${peerId} returned invalid response shape: ${issues}`,
peerId,
});
}
return result.data as T;
}
}
@@ -0,0 +1,23 @@
/**
* Federation client barrel — re-exports for FederationModule consumers.
*
* M3-09 (QuerySourceService) and future milestones should import from here,
* not directly from the implementation file.
*/
export {
FederationClientService,
FederationClientError,
type FederationClientErrorCode,
type FederationClientErrorOptions,
} from './federation-client.service.js';
export {
QuerySourceService,
QuerySourceError,
type QuerySource,
type QuerySourceErrorCode,
type QuerySourceErrorOptions,
type QuerySourceListOptions,
type QuerySourceListResponse,
type LocalListExecutor,
} from './query-source.service.js';
@@ -0,0 +1,261 @@
/**
* QuerySourceService — gateway query source router (FED-M3-09).
*
* Accepts the federation query-layer `source` selector and routes list-style
* reads to local storage, one federated peer, or all active outbound peers.
*
* `source: "all"` is intentionally tolerant of per-peer failures: local data
* and successful peer responses are returned, and the envelope is marked
* `_partial: true`. Local failures still reject because there is no safe local
* fallback and the gateway's own storage is expected to be authoritative.
*/
import { Inject, Injectable, Logger } from '@nestjs/common';
import { and, eq, federationPeers, isNotNull, type Db } from '@mosaicstack/db';
import {
SOURCE_LOCAL,
tagWithSource,
type FederationListResponse,
type SourceTag,
} from '@mosaicstack/types';
import { DB } from '../../database/database.module.js';
import { FederationClientService } from './federation-client.service.js';
export type QuerySource = 'local' | 'all' | `federated:${string}`;
export type QuerySourceErrorCode = 'INVALID_SOURCE' | 'PEER_NOT_FOUND';
export interface QuerySourceErrorOptions {
code: QuerySourceErrorCode;
message: string;
source: string;
}
export class QuerySourceError extends Error {
readonly code: QuerySourceErrorCode;
readonly source: string;
constructor(opts: QuerySourceErrorOptions) {
super(opts.message);
this.name = 'QuerySourceError';
this.code = opts.code;
this.source = opts.source;
}
}
export type LocalListExecutor<T extends object> = () => Promise<FederationListResponse<T> | T[]>;
export interface QuerySourceListOptions<T extends object> {
source: QuerySource;
resource: string;
request?: Record<string, unknown>;
local: LocalListExecutor<T>;
}
export type QuerySourceListResponse<T extends object> = FederationListResponse<T & SourceTag>;
interface OutboundPeer {
id: string;
commonName: string;
endpointUrl: string;
}
interface TaggedList<T extends object> {
items: Array<T & SourceTag>;
partial: boolean;
truncated: boolean;
nextCursor?: string;
}
@Injectable()
export class QuerySourceService {
private readonly logger = new Logger(QuerySourceService.name);
constructor(
@Inject(DB) private readonly db: Db,
@Inject(FederationClientService) private readonly federationClient: FederationClientService,
) {}
async list<T extends object>(
options: QuerySourceListOptions<T>,
): Promise<QuerySourceListResponse<T>> {
const request = options.request ?? {};
if (options.source === 'local') {
const local = await this.runLocal(options.local);
return this.toResponse(this.tagList(local, SOURCE_LOCAL));
}
if (options.source === 'all') {
return this.listAll(options.resource, request, options.local);
}
if (options.source.startsWith('federated:')) {
const host = options.source.slice('federated:'.length).trim();
if (!host) {
throw new QuerySourceError({
code: 'INVALID_SOURCE',
message: 'Federated source must include a host after federated:',
source: options.source,
});
}
const peer = await this.findPeerByHost(host, options.source);
const remote = await this.federationClient.list<T>(peer.id, options.resource, request);
return this.toResponse(this.tagList(remote, peer.commonName));
}
throw new QuerySourceError({
code: 'INVALID_SOURCE',
message: `Unsupported query source: ${options.source}`,
source: options.source,
});
}
private async listAll<T extends object>(
resource: string,
request: Record<string, unknown>,
local: LocalListExecutor<T>,
): Promise<QuerySourceListResponse<T>> {
const peers = await this.listActiveOutboundPeers();
const localPromise = this.runLocal(local).then((response) =>
this.tagList(response, SOURCE_LOCAL),
);
const remotePromises = peers.map(async (peer: OutboundPeer): Promise<TaggedList<T> | null> => {
try {
const response = await this.federationClient.list<T>(peer.id, resource, request);
return this.tagList(response, peer.commonName);
} catch (error: unknown) {
this.logger.warn(
`Federated query to peer ${peer.commonName} (${peer.id}) failed; returning partial all-source response: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
}
});
const [localResult, ...remoteResults] = await Promise.all([localPromise, ...remotePromises]);
const successfulRemoteResults = remoteResults.filter(
(result: TaggedList<T> | null): result is TaggedList<T> => result !== null,
);
const allResults = [localResult, ...successfulRemoteResults];
const peerFailure = successfulRemoteResults.length !== peers.length;
return this.mergeTaggedLists(allResults, peerFailure);
}
private async runLocal<T extends object>(
local: LocalListExecutor<T>,
): Promise<FederationListResponse<T>> {
const response = await local();
if (Array.isArray(response)) {
return { items: response };
}
return response;
}
private tagList<T extends object>(
response: FederationListResponse<T>,
source: string,
): TaggedList<T> {
return {
items: tagWithSource(response.items, source),
partial: response._partial === true,
truncated: response._truncated === true || response.nextCursor !== undefined,
nextCursor: response.nextCursor,
};
}
private mergeTaggedLists<T extends object>(
lists: Array<TaggedList<T>>,
peerFailure: boolean,
): QuerySourceListResponse<T> {
const items = lists.flatMap((list: TaggedList<T>) => list.items);
const partial =
peerFailure ||
lists.some((list: TaggedList<T>) => list.partial || list.nextCursor !== undefined);
const truncated = lists.some((list: TaggedList<T>) => list.truncated);
const response: QuerySourceListResponse<T> = { items };
if (partial) {
response._partial = true;
}
if (truncated) {
response._truncated = true;
}
return response;
}
private toResponse<T extends object>(tagged: TaggedList<T>): QuerySourceListResponse<T> {
const response: QuerySourceListResponse<T> = {
items: tagged.items,
};
if (tagged.nextCursor !== undefined) {
response.nextCursor = tagged.nextCursor;
}
if (tagged.partial) {
response._partial = true;
}
if (tagged.truncated) {
response._truncated = true;
}
return response;
}
private async findPeerByHost(sourceHost: string, source: string): Promise<OutboundPeer> {
const host = normalizeHost(sourceHost);
const peers = await this.listActiveOutboundPeers();
const peer = peers.find((candidate: OutboundPeer) => {
const commonName = normalizeHost(candidate.commonName);
const endpointHosts = endpointHostKeys(candidate.endpointUrl).map((endpointHost: string) =>
normalizeHost(endpointHost),
);
return commonName === host || endpointHosts.includes(host);
});
if (!peer) {
throw new QuerySourceError({
code: 'PEER_NOT_FOUND',
message: `No active outbound federation peer matches source ${source}`,
source,
});
}
return peer;
}
private async listActiveOutboundPeers(): Promise<OutboundPeer[]> {
const rows = await this.db
.select({
id: federationPeers.id,
commonName: federationPeers.commonName,
endpointUrl: federationPeers.endpointUrl,
})
.from(federationPeers)
.where(
and(
eq(federationPeers.state, 'active'),
isNotNull(federationPeers.endpointUrl),
isNotNull(federationPeers.clientKeyPem),
),
)
.orderBy(federationPeers.commonName);
return rows.filter((row): row is OutboundPeer => typeof row.endpointUrl === 'string');
}
}
function normalizeHost(host: string): string {
return host.trim().toLowerCase();
}
function endpointHostKeys(endpointUrl: string): string[] {
try {
const url = new URL(endpointUrl);
return Array.from(new Set([url.host, url.hostname].filter((host: string) => host.length > 0)));
} catch {
return [];
}
}
@@ -4,11 +4,35 @@ import { CaService } from './ca.service.js';
import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({
controllers: [EnrollmentController, FederationController],
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
exports: [CaService, EnrollmentService, GrantsService],
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
providers: [
AdminGuard,
CaService,
EnrollmentService,
GrantsService,
FederationClientService,
QuerySourceService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
exports: [
CaService,
EnrollmentService,
GrantsService,
FederationClientService,
QuerySourceService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
})
export class FederationModule {}
+30 -1
View File
@@ -10,12 +10,14 @@
*/
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { type Db, and, eq, federationGrants } from '@mosaicstack/db';
import { type Db, and, eq, federationGrants, federationPeers } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { parseFederationScope } from './scope-schema.js';
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
export type Grant = typeof federationGrants.$inferSelect;
export type Peer = typeof federationPeers.$inferSelect;
export type GrantWithPeer = Grant & { peer: Peer };
@Injectable()
export class GrantsService {
@@ -60,6 +62,33 @@ export class GrantsService {
return grant;
}
/**
* Fetch a single grant by ID, joined with its associated peer row.
* Used by FederationAuthGuard to perform grant status + cert serial checks
* in a single DB round-trip.
*
* Throws NotFoundException if the grant does not exist.
* Throws NotFoundException if the associated peer row is missing (data integrity issue).
*/
async getGrantWithPeer(id: string): Promise<GrantWithPeer> {
const rows = await this.db
.select()
.from(federationGrants)
.innerJoin(federationPeers, eq(federationGrants.peerId, federationPeers.id))
.where(eq(federationGrants.id, id))
.limit(1);
const row = rows[0];
if (!row) {
throw new NotFoundException(`Grant ${id} not found`);
}
return {
...row.federation_grants,
peer: row.federation_peers,
};
}
/**
* List grants with optional filters for peerId, subjectUserId, and status.
*/
+146
View File
@@ -0,0 +1,146 @@
/**
* Shared OID extraction helpers for Mosaic federation certificates.
*
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
* 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
*
* The encoding convention: each extension value is an OCTET STRING wrapping
* an ASN.1 UTF8String TLV:
* 0x0C (tag) + 1-byte length + UTF-8 bytes
*
* CaService encodes values this way via encodeUtf8String(), and this module
* decodes them with the corresponding `.slice(2)` to skip tag + length byte.
*
* This module is intentionally pure — no NestJS, no DB, no network I/O.
*/
import { X509Certificate } from '@peculiar/x509';
// ---------------------------------------------------------------------------
// OID constants
// ---------------------------------------------------------------------------
export const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1';
export const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2';
// ---------------------------------------------------------------------------
// Extraction result types
// ---------------------------------------------------------------------------
export interface MosaicOids {
grantId: string;
subjectUserId: string;
}
export type OidExtractionResult =
| { ok: true; value: MosaicOids }
| {
ok: false;
error: 'MISSING_GRANT_ID' | 'MISSING_SUBJECT_USER_ID' | 'PARSE_ERROR';
detail?: string;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const decoder = new TextDecoder();
/**
* Decode an extension value encoded as ASN.1 UTF8String TLV
* (tag 0x0C + 1-byte length + UTF-8 bytes).
* Validates tag, length byte, and buffer bounds before decoding.
* Throws a descriptive Error on malformed input; caller wraps in try/catch.
*/
function decodeUtf8StringTlv(value: ArrayBuffer): string {
const bytes = new Uint8Array(value);
// Need at least tag + length bytes
if (bytes.length < 2) {
throw new Error(`UTF8String TLV too short: expected at least 2 bytes, got ${bytes.length}`);
}
// Tag byte must be 0x0C (ASN.1 UTF8String)
if (bytes[0] !== 0x0c) {
throw new Error(
`UTF8String TLV tag mismatch: expected 0x0C, got 0x${bytes[0]!.toString(16).toUpperCase()}`,
);
}
// Only single-byte length form is supported (values 0127); long form not needed
// for OID strings of this length.
const declaredLength = bytes[1]!;
if (declaredLength > 127) {
throw new Error(
`UTF8String TLV uses long-form length (0x${declaredLength.toString(16).toUpperCase()}), which is not supported`,
);
}
// Declared length must match actual remaining bytes
if (declaredLength !== bytes.length - 2) {
throw new Error(
`UTF8String TLV length mismatch: declared ${declaredLength}, actual ${bytes.length - 2}`,
);
}
// Skip: tag (1 byte) + length (1 byte)
return decoder.decode(bytes.slice(2));
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Extract Mosaic custom OIDs (grantId, subjectUserId) from an X.509 certificate
* already parsed via @peculiar/x509.
*
* Returns `{ ok: true, value: MosaicOids }` on success, or
* `{ ok: false, error: <code>, detail? }` on any failure — never throws.
*/
export function extractMosaicOids(cert: X509Certificate): OidExtractionResult {
try {
const grantIdExt = cert.getExtension(OID_MOSAIC_GRANT_ID);
if (!grantIdExt) {
return { ok: false, error: 'MISSING_GRANT_ID' };
}
const subjectUserIdExt = cert.getExtension(OID_MOSAIC_SUBJECT_USER_ID);
if (!subjectUserIdExt) {
return { ok: false, error: 'MISSING_SUBJECT_USER_ID' };
}
const grantId = decodeUtf8StringTlv(grantIdExt.value);
const subjectUserId = decodeUtf8StringTlv(subjectUserIdExt.value);
return {
ok: true,
value: { grantId, subjectUserId },
};
} catch (err) {
return {
ok: false,
error: 'PARSE_ERROR',
detail: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Parse a PEM-encoded certificate and extract Mosaic OIDs.
* Returns an OidExtractionResult — never throws.
*/
export function extractMosaicOidsFromPem(certPem: string): OidExtractionResult {
let cert: X509Certificate;
try {
cert = new X509Certificate(certPem);
} catch (err) {
return {
ok: false,
error: 'PARSE_ERROR',
detail: err instanceof Error ? err.message : String(err),
};
}
return extractMosaicOids(cert);
}
@@ -0,0 +1,521 @@
/**
* Unit tests for FederationAuthGuard (FED-M3-03).
*
* Coverage:
* - Missing cert (no TLS socket / no getPeerCertificate) → 401
* - Cert parse failure (corrupt DER raw bytes) → 401
* - Missing grantId OID → 401
* - Missing subjectUserId OID → 401
* - Grant not found (GrantsService throws NotFoundException) → 403
* - Grant in `pending` status → 403
* - Grant in `revoked` status → 403
* - Grant in `expired` status → 403
* - Cert serial mismatch → 403
* - Happy path: active grant + matching cert serial → context attached, returns true
*/
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ExecutionContext } from '@nestjs/common';
import { NotFoundException } from '@nestjs/common';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import { makeMosaicIssuedCert } from '../../__tests__/helpers/test-cert.js';
import type { GrantsService, GrantWithPeer } from '../../grants.service.js';
// ---------------------------------------------------------------------------
// Test constants
// ---------------------------------------------------------------------------
const GRANT_ID = 'a1111111-1111-1111-1111-111111111111';
const USER_ID = 'b2222222-2222-2222-2222-222222222222';
const PEER_ID = 'c3333333-3333-3333-3333-333333333333';
// Node.js TLS serialNumber is uppercase hex (no colons)
const CERT_SERIAL_HEX = '01';
const VALID_SCOPE = { resources: ['tasks'], max_rows_per_query: 100 };
// ---------------------------------------------------------------------------
// Mock builders
// ---------------------------------------------------------------------------
/**
* Build a minimal GrantWithPeer-shaped mock.
*/
function makeGrantWithPeer(overrides: Partial<GrantWithPeer> = {}): GrantWithPeer {
return {
id: GRANT_ID,
peerId: PEER_ID,
subjectUserId: USER_ID,
scope: VALID_SCOPE,
status: 'active',
expiresAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
revokedAt: null,
revokedReason: null,
peer: {
id: PEER_ID,
commonName: 'test-peer',
displayName: 'Test Peer',
certPem: '',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
clientKeyPem: null,
state: 'active',
endpointUrl: null,
lastSeenAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
revokedAt: null,
},
...overrides,
};
}
/**
* Build a mock ExecutionContext with a pre-built TLS peer certificate.
*
* `certPem` — PEM string to present as the raw DER cert (converted to Buffer).
* Pass null to simulate "no cert presented".
* `certSerialHex` — serialNumber string returned by the TLS socket.
* Node.js returns uppercase hex.
* `hasTlsSocket` — if false, raw.socket has no getPeerCertificate (plain HTTP).
*/
function makeContext(opts: {
certPem: string | null;
certSerialHex?: string;
hasTlsSocket?: boolean;
}): {
ctx: ExecutionContext;
statusMock: ReturnType<typeof vi.fn>;
sendMock: ReturnType<typeof vi.fn>;
} {
const { certPem, certSerialHex = CERT_SERIAL_HEX, hasTlsSocket = true } = opts;
// Build peerCert object that Node.js TLS socket.getPeerCertificate() returns
let peerCert: Record<string, unknown>;
if (certPem === null) {
// Simulate no cert: Node.js returns object with empty string fields
peerCert = { raw: null, serialNumber: '' };
} else {
// Convert PEM to DER Buffer (strip headers + base64 decode)
const b64 = certPem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, '');
const raw = Buffer.from(b64, 'base64');
peerCert = { raw, serialNumber: certSerialHex };
}
const getPeerCertificate = vi.fn().mockReturnValue(peerCert);
const socket = hasTlsSocket ? { getPeerCertificate } : {}; // No getPeerCertificate → non-TLS
// Fastify reply mocks
const sendMock = vi.fn().mockReturnValue(undefined);
const headerMock = vi.fn().mockReturnValue({ send: sendMock });
const statusMock = vi.fn().mockReturnValue({ header: headerMock });
const request = {
raw: {
socket,
},
};
const reply = {
status: statusMock,
};
const ctx = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
return { ctx, statusMock, sendMock };
}
/**
* Build a mock GrantsService.
*/
function makeGrantsService(
overrides: Partial<Pick<GrantsService, 'getGrantWithPeer'>> = {},
): GrantsService {
return {
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer()),
...overrides,
} as unknown as GrantsService;
}
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
describe('FederationAuthGuard', () => {
let certPem: string;
beforeEach(async () => {
// Generate a real Mosaic-issued cert with the standard OIDs
certPem = await makeMosaicIssuedCert({ grantId: GRANT_ID, subjectUserId: USER_ID });
});
// ── 401: No TLS socket ────────────────────────────────────────────────────
it('returns 401 when there is no TLS socket (plain HTTP connection)', async () => {
const { ctx, statusMock, sendMock } = makeContext({
certPem: certPem,
hasTlsSocket: false,
});
const guard = new FederationAuthGuard(makeGrantsService());
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(401);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
}),
);
});
// ── 401: Cert not presented ───────────────────────────────────────────────
it('returns 401 when the peer did not present a certificate', async () => {
const { ctx, statusMock, sendMock } = makeContext({ certPem: null });
const guard = new FederationAuthGuard(makeGrantsService());
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(401);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
}),
);
});
// ── 401: Cert parse failure ───────────────────────────────────────────────
it('returns 401 when the certificate DER bytes are corrupt', async () => {
// Build context with a cert that has garbage DER bytes
const corruptPem = '-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----';
const { ctx, statusMock, sendMock } = makeContext({ certPem: corruptPem });
const guard = new FederationAuthGuard(makeGrantsService());
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(401);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
}),
);
});
// ── 401: Missing grantId OID ─────────────────────────────────────────────
it('returns 401 when the cert is missing the grantId OID', async () => {
// makeSelfSignedCert produces a cert without any Mosaic OIDs
const { makeSelfSignedCert } = await import('../../__tests__/helpers/test-cert.js');
const plainCert = await makeSelfSignedCert();
const { ctx, statusMock, sendMock } = makeContext({ certPem: plainCert });
const guard = new FederationAuthGuard(makeGrantsService());
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(401);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
}),
);
});
// ── 401: Missing subjectUserId OID ───────────────────────────────────────
it('returns 401 when the cert has grantId OID but is missing subjectUserId OID', async () => {
// Build a cert with only the grantId OID by importing cert generator internals
const { webcrypto } = await import('node:crypto');
const {
X509CertificateGenerator,
Extension,
KeyUsagesExtension,
KeyUsageFlags,
BasicConstraintsExtension,
cryptoProvider,
} = await import('@peculiar/x509');
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
const now = new Date();
const tomorrow = new Date(now.getTime() + 86_400_000);
// Encode grantId only — missing subjectUserId extension
const utf8 = new TextEncoder().encode(GRANT_ID);
const encoded = new Uint8Array(2 + utf8.length);
encoded[0] = 0x0c;
encoded[1] = utf8.length;
encoded.set(utf8, 2);
const cert = await X509CertificateGenerator.createSelfSigned({
serialNumber: '01',
name: 'CN=partial-oid-test',
notBefore: now,
notAfter: tomorrow,
signingAlgorithm: alg,
keys,
extensions: [
new BasicConstraintsExtension(false),
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
new Extension('1.3.6.1.4.1.99999.1', false, encoded), // grantId only
],
});
const { ctx, statusMock, sendMock } = makeContext({ certPem: cert.toString('pem') });
const guard = new FederationAuthGuard(makeGrantsService());
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(401);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
}),
);
});
// ── 403: Grant not found ─────────────────────────────────────────────────
it('returns 403 when the grantId from the cert does not exist in DB', async () => {
const grantsService = makeGrantsService({
getGrantWithPeer: vi
.fn()
.mockRejectedValue(new NotFoundException(`Grant ${GRANT_ID} not found`)),
});
const { ctx, statusMock, sendMock } = makeContext({ certPem });
const guard = new FederationAuthGuard(grantsService);
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(403);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
}),
);
});
// ── 403: Grant in `pending` status ───────────────────────────────────────
it('returns 403 when the grant is in pending status', async () => {
const grantsService = makeGrantsService({
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'pending' })),
});
const { ctx, statusMock, sendMock } = makeContext({ certPem });
const guard = new FederationAuthGuard(grantsService);
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(403);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
}),
);
});
// ── 403: Grant in `revoked` status ───────────────────────────────────────
it('returns 403 when the grant is in revoked status', async () => {
const grantsService = makeGrantsService({
getGrantWithPeer: vi
.fn()
.mockResolvedValue(makeGrantWithPeer({ status: 'revoked', revokedAt: new Date() })),
});
const { ctx, statusMock, sendMock } = makeContext({ certPem });
const guard = new FederationAuthGuard(grantsService);
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(403);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
}),
);
});
// ── 403: Grant in `expired` status ───────────────────────────────────────
it('returns 403 when the grant is in expired status', async () => {
const grantsService = makeGrantsService({
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'expired' })),
});
const { ctx, statusMock, sendMock } = makeContext({ certPem });
const guard = new FederationAuthGuard(grantsService);
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(403);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
}),
);
});
// ── 403: Cert serial mismatch ─────────────────────────────────────────────
it('returns 403 when the cert serial does not match the registered peer cert serial', async () => {
// Return a grant whose peer has a different stored serial
const grantsService = makeGrantsService({
getGrantWithPeer: vi.fn().mockResolvedValue(
makeGrantWithPeer({
peer: {
id: PEER_ID,
commonName: 'test-peer',
displayName: 'Test Peer',
certPem: '',
certSerial: 'DEADBEEF', // different from CERT_SERIAL_HEX='01'
certNotAfter: new Date(Date.now() + 86_400_000),
clientKeyPem: null,
state: 'active',
endpointUrl: null,
lastSeenAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
revokedAt: null,
},
}),
),
});
// Context presents cert with serial '01' but DB has 'DEADBEEF'
const { ctx, statusMock, sendMock } = makeContext({ certPem, certSerialHex: '01' });
const guard = new FederationAuthGuard(grantsService);
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(403);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
}),
);
});
// ── 403: subjectUserId cert/DB mismatch (CRIT-1 regression test) ─────────
it('returns 403 when the cert subjectUserId does not match the DB grant subjectUserId', async () => {
// Build a cert that claims an attacker's subjectUserId
const attackerSubjectUserId = 'attacker-user-id';
const attackerCertPem = await makeMosaicIssuedCert({
grantId: GRANT_ID,
subjectUserId: attackerSubjectUserId,
});
// DB returns a grant with the legitimate USER_ID
const grantsService = makeGrantsService({
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ subjectUserId: USER_ID })),
});
// Cert presents attacker-user-id but DB has USER_ID — should be rejected
const { ctx, statusMock, sendMock } = makeContext({
certPem: attackerCertPem,
certSerialHex: CERT_SERIAL_HEX,
});
const guard = new FederationAuthGuard(grantsService);
const result = await guard.canActivate(ctx);
expect(result).toBe(false);
expect(statusMock).toHaveBeenCalledWith(403);
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
}),
);
});
// ── Happy path ────────────────────────────────────────────────────────────
it('returns true and attaches federationContext on happy path', async () => {
const grant = makeGrantWithPeer({
status: 'active',
peer: {
id: PEER_ID,
commonName: 'test-peer',
displayName: 'Test Peer',
certPem: '',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
clientKeyPem: null,
state: 'active',
endpointUrl: null,
lastSeenAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
revokedAt: null,
},
});
const grantsService = makeGrantsService({
getGrantWithPeer: vi.fn().mockResolvedValue(grant),
});
// Build context manually to capture what gets set on request.federationContext
const b64 = certPem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, '');
const raw = Buffer.from(b64, 'base64');
const peerCert = { raw, serialNumber: CERT_SERIAL_HEX };
const sendMock = vi.fn().mockReturnValue(undefined);
const headerMock = vi.fn().mockReturnValue({ send: sendMock });
const statusMock = vi.fn().mockReturnValue({ header: headerMock });
const request: Record<string, unknown> = {
raw: {
socket: { getPeerCertificate: vi.fn().mockReturnValue(peerCert) },
},
};
const reply = { status: statusMock };
const ctx = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
const guard = new FederationAuthGuard(grantsService);
const result = await guard.canActivate(ctx);
expect(result).toBe(true);
expect(statusMock).not.toHaveBeenCalled();
// Verify the context was attached correctly
expect(request['federationContext']).toEqual({
grantId: GRANT_ID,
subjectUserId: USER_ID,
peerId: PEER_ID,
scope: VALID_SCOPE,
});
});
});
@@ -0,0 +1,324 @@
/**
* Unit tests for FederationScopeService (FED-M3-04).
*
* Coverage:
* - resource allowlist deny
* - excluded resource deny
* - invalid scope deny
* - invalid requested limit deny
* - native RBAC deny as subjectUserId
* - scope/native filter intersection for personal and team rows
* - native RBAC personal deny wins over scope include_personal allow/default
* - max_rows_per_query cap
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
import type { FederationContext } from '../federation-context.js';
const GRANT_ID = 'grant-1';
const PEER_ID = 'peer-1';
const SUBJECT_USER_ID = 'user-1';
function makeContext(scope: Record<string, unknown>): FederationContext {
return {
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
scope,
};
}
function makeNativeRbac(
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
): FederationNativeRbacEvaluator {
return {
evaluateReadAccess: vi.fn().mockResolvedValue(result),
};
}
describe('FederationScopeService', () => {
let service: FederationScopeService;
beforeEach(() => {
service = new FederationScopeService();
});
it('allows a granted resource and returns a capped query filter', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
max_rows_per_query: 50,
}),
resource: 'tasks',
requestedLimit: 500,
nativeRbac,
});
expect(result).toEqual({
allowed: true,
filter: {
resource: 'tasks',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: ['team-1'],
limit: 50,
maxRowsPerQuery: 50,
},
});
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
});
});
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
const result = await service.evaluateAccess({
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
resource: 'notes',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: true,
teamIds: ['team-1', 'team-2'],
limit: 100,
},
});
});
it('honors include_personal false even when native RBAC allows personal rows', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['memory'],
filters: { memory: { include_personal: false } },
max_rows_per_query: 25,
}),
resource: 'memory',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: [],
},
});
});
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_personal: true } },
max_rows_per_query: 25,
}),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: false, teamIds: ['team-1'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: ['team-1'],
},
});
});
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
max_rows_per_query: 25,
}),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: [],
},
});
});
it('denies invalid grant scope before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: [], max_rows_per_query: 100 }),
resource: 'tasks',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_scope',
stage: 'scope_parse',
statusCode: 400,
grantId: GRANT_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies unsupported resource names before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'unknown_resource',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_resource',
stage: 'resource_allowlist',
statusCode: 403,
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
excluded_resources: ['credentials'],
max_rows_per_query: 100,
}),
resource: 'credentials',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
resource: 'credentials',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies supported resources that are not granted by scope', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'notes',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'resource_not_granted',
stage: 'resource_allowlist',
statusCode: 403,
resource: 'notes',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies invalid requested row limits before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'tasks',
requestedLimit: 0,
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_limit',
stage: 'row_cap',
statusCode: 400,
details: { requestedLimit: 0 },
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: false,
reason: 'read:tasks denied',
details: { permission: 'tasks:read' },
}),
});
expect(result).toEqual({
allowed: false,
deny: {
code: 'native_rbac_denied',
stage: 'native_rbac',
statusCode: 403,
message: 'read:tasks denied',
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
details: { permission: 'tasks:read' },
},
});
});
});
@@ -0,0 +1,212 @@
/**
* FederationAuthGuard — NestJS CanActivate guard for inbound federation requests.
*
* Validates the mTLS client certificate presented by a peer gateway, extracts
* custom OIDs to identify the grant + subject user, loads the grant from DB,
* asserts it is active, and verifies the cert serial against the registered peer
* cert serial as a defense-in-depth measure.
*
* On success, attaches `request.federationContext` for downstream verb controllers.
* On failure, responds with the federation wire-format error envelope (not raw
* NestJS exception JSON) to match the federation protocol contract.
*
* ## Cert-serial check decision
* The guard validates that the inbound client cert's serial number matches the
* `certSerial` stored on the associated `federation_peers` row. This is a
* defense-in-depth measure: even if the mTLS handshake is compromised at the
* transport layer (e.g. misconfigured TLS terminator that forwards arbitrary
* client certs), an attacker cannot replay a cert with a different serial than
* what was registered during enrollment. This check is NOT loosened because:
* 1. It is O(1) — no additional DB round-trip (peerId is on the grant row,
* so we join to federationPeers in the same query).
* 2. Cert renewal MUST update the stored serial — enforced by M6 scheduler.
* 3. The OID-only path (without serial check) would allow any cert from the
* same CA bearing the same grantId OID to succeed after cert compromise.
*
* ## FastifyRequest typing path
* NestJS + Fastify wraps the raw Node.js IncomingMessage in a FastifyRequest.
* The underlying TLS socket is accessed via `request.raw.socket`, which is a
* `tls.TLSSocket` when the server is listening on HTTPS. In development/test
* the gateway may run over plain HTTP, in which case `getPeerCertificate` is
* not available. The guard safely handles both cases by checking for the
* method's existence before calling it.
*
* Note: The guard reads the peer certificate from the *already-completed*
* TLS handshake via `socket.getPeerCertificate(detailed=true)`. This relies
* on the server being configured with `requestCert: true` at the TLS level
* so Fastify/Node.js requests the client cert during the handshake.
* The guard does NOT verify the cert chain itself — that is handled by the
* TLS layer (Node.js `rejectUnauthorized: true` with the CA cert pinned).
*/
import {
type CanActivate,
type ExecutionContext,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as tls from 'node:tls';
import { X509Certificate } from '@peculiar/x509';
import { FederationForbiddenError, FederationUnauthorizedError } from '@mosaicstack/types';
import { extractMosaicOids } from '../oid.util.js';
import { GrantsService } from '../grants.service.js';
import type { FederationContext } from './federation-context.js';
import './federation-context.js'; // side-effect import: applies FastifyRequest module augmentation
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Send a federation wire-format error response directly on the Fastify reply.
* Returns false — callers return this value from canActivate.
*/
function sendFederationError(
reply: FastifyReply,
error: FederationUnauthorizedError | FederationForbiddenError,
): boolean {
const statusCode = error.code === 'unauthorized' ? 401 : 403;
void reply.status(statusCode).header('content-type', 'application/json').send(error.toEnvelope());
return false;
}
// ---------------------------------------------------------------------------
// Guard
// ---------------------------------------------------------------------------
@Injectable()
export class FederationAuthGuard implements CanActivate {
private readonly logger = new Logger(FederationAuthGuard.name);
constructor(@Inject(GrantsService) private readonly grantsService: GrantsService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const http = context.switchToHttp();
const request = http.getRequest<FastifyRequest>();
const reply = http.getResponse<FastifyReply>();
// ── Step 1: Extract peer certificate from TLS socket ────────────────────
const rawSocket = request.raw.socket;
// Check TLS socket: getPeerCertificate is only available on TLS connections.
if (
!rawSocket ||
typeof (rawSocket as Partial<tls.TLSSocket>).getPeerCertificate !== 'function'
) {
this.logger.warn('No TLS socket — client cert unavailable (non-mTLS connection)');
return sendFederationError(
reply,
new FederationUnauthorizedError('Client certificate required'),
);
}
const tlsSocket = rawSocket as tls.TLSSocket;
const peerCert = tlsSocket.getPeerCertificate(true);
// Node.js returns an object with empty string fields when no cert was presented.
if (!peerCert || !peerCert.raw) {
this.logger.warn('Peer certificate not presented (mTLS handshake did not supply cert)');
return sendFederationError(
reply,
new FederationUnauthorizedError('Client certificate required'),
);
}
// ── Step 2: Parse the DER-encoded certificate via @peculiar/x509 ────────
let cert: X509Certificate;
try {
// peerCert.raw is a Buffer containing the DER-encoded cert
cert = new X509Certificate(peerCert.raw);
} catch (err) {
this.logger.warn(
`Failed to parse peer certificate: ${err instanceof Error ? err.message : String(err)}`,
);
return sendFederationError(
reply,
new FederationUnauthorizedError('Client certificate could not be parsed'),
);
}
// ── Step 3: Extract Mosaic custom OIDs ──────────────────────────────────
const oidResult = extractMosaicOids(cert);
if (!oidResult.ok) {
const message =
oidResult.error === 'MISSING_GRANT_ID'
? 'Client certificate is missing required OID: mosaic_grant_id (1.3.6.1.4.1.99999.1)'
: oidResult.error === 'MISSING_SUBJECT_USER_ID'
? 'Client certificate is missing required OID: mosaic_subject_user_id (1.3.6.1.4.1.99999.2)'
: `Client certificate OID extraction failed: ${oidResult.detail ?? 'unknown error'}`;
this.logger.warn(`OID extraction failure [${oidResult.error}]: ${message}`);
return sendFederationError(reply, new FederationUnauthorizedError(message));
}
const { grantId, subjectUserId } = oidResult.value;
// ── Step 4: Load grant from DB ───────────────────────────────────────────
let grant: Awaited<ReturnType<GrantsService['getGrantWithPeer']>>;
try {
grant = await this.grantsService.getGrantWithPeer(grantId);
} catch {
// getGrantWithPeer throws NotFoundException when not found
this.logger.warn(`Grant not found: ${grantId}`);
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
}
// ── Step 5: Assert grant is active ──────────────────────────────────────
if (grant.status !== 'active') {
this.logger.warn(`Grant ${grantId} is not active — status=${grant.status}`);
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
}
// ── Step 5b: Validate cert-extracted subjectUserId against DB (CRIT-1) ──
// The cert claim is untrusted input; the DB row is authoritative.
if (subjectUserId !== grant.subjectUserId) {
this.logger.warn(`subjectUserId mismatch for grant ${grantId}`);
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
}
// ── Step 6: Defense-in-depth — cert serial must match registered peer ───
// The serial number from Node.js TLS is upper-case hex without colons.
// The @peculiar/x509 serialNumber is decimal. We compare using the native
// Node.js crypto cert serial which is uppercase hex, matching DB storage.
// Both are derived from the peerCert.serialNumber Node.js provides.
const inboundSerial: string = peerCert.serialNumber ?? '';
if (!grant.peer.certSerial) {
// Peer row exists but has no stored serial — something is wrong with enrollment
this.logger.error(`Peer ${grant.peerId} has no stored certSerial — enrollment incomplete`);
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
}
// Normalize both to uppercase for comparison (Node.js serialNumber is
// already uppercase hex; DB value was stored from extractSerial() which
// returns crypto.X509Certificate.serialNumber — also uppercase hex).
if (inboundSerial.toUpperCase() !== grant.peer.certSerial.toUpperCase()) {
this.logger.warn(
`Cert serial mismatch for grant ${grantId}: ` +
`inbound=${inboundSerial} registered=${grant.peer.certSerial}`,
);
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
}
// ── Step 7: Attach FederationContext to request ──────────────────────────
// Use grant.subjectUserId from DB (authoritative) — not the cert-extracted value.
const federationContext: FederationContext = {
grantId,
subjectUserId: grant.subjectUserId,
peerId: grant.peerId,
scope: grant.scope as Record<string, unknown>,
};
request.federationContext = federationContext;
this.logger.debug(
`Federation auth OK — grantId=${grantId} peerId=${grant.peerId} subjectUserId=${grant.subjectUserId}`,
);
return true;
}
}
@@ -0,0 +1,39 @@
/**
* FederationContext — attached to inbound federation requests after successful
* mTLS + grant validation by FederationAuthGuard.
*
* Downstream verb controllers access this via `request.federationContext`.
*/
/**
* Augment FastifyRequest so TypeScript knows about the federation context
* property that FederationAuthGuard attaches on success.
*/
declare module 'fastify' {
interface FastifyRequest {
federationContext?: FederationContext;
}
}
/**
* Typed context object attached to the request by FederationAuthGuard.
* Carries all data extracted from the mTLS cert + grant DB row needed
* by downstream federation verb handlers.
*/
export interface FederationContext {
/** The federation grant ID extracted from OID 1.3.6.1.4.1.99999.1 */
grantId: string;
/** The local subject user whose data is accessible under this grant */
subjectUserId: string;
/** The peer gateway ID (from the grant's peerId FK) */
peerId: string;
/**
* Grant scope — determines which resources the peer may query.
* Typed as Record<string, unknown> because the full scope schema lives in
* scope-schema.ts; downstream handlers should narrow via parseFederationScope.
*/
scope: Record<string, unknown>;
}
@@ -0,0 +1,31 @@
/**
* Federation server-side barrel — inbound request handling.
*
* Exports the mTLS auth guard and the FederationContext interface
* for use by verb controllers (M3-05/06/07).
*
* Usage:
* import { FederationAuthGuard } from './server/index.js';
* @UseGuards(FederationAuthGuard)
*/
export { FederationAuthGuard } from './federation-auth.guard.js';
export { FederationScopeService } from './scope.service.js';
export type { FederationContext } from './federation-context.js';
export type {
FederationNativeRbacAccess,
FederationNativeRbacAllowedResult,
FederationNativeRbacDeniedResult,
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeAllowedResult,
FederationScopeDeniedResult,
FederationScopeDenyCode,
FederationScopeDenyDetails,
FederationScopeDenyReason,
FederationScopeDenyStage,
FederationScopeEvaluationInput,
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from './scope.service.js';
@@ -0,0 +1,272 @@
/**
* FederationScopeService — M3 server-side scope enforcement pipeline.
*
* Pure trust-boundary service: it validates the grant scope, asks an injected
* native RBAC evaluator what the subject user can read locally, intersects that
* answer with the federation scope filters, and returns a query filter for the
* verb controllers. The service performs no DB calls directly.
*/
import { Injectable } from '@nestjs/common';
import {
FEDERATION_RESOURCE_VALUES,
type FederationResource,
FederationScopeError,
parseFederationScope,
} from '../scope-schema.js';
import type { FederationContext } from './federation-context.js';
const federationResourceSet: ReadonlySet<string> = new Set<string>(FEDERATION_RESOURCE_VALUES);
export type FederationScopeDenyStage =
| 'scope_parse'
| 'resource_allowlist'
| 'resource_exclusion'
| 'native_rbac'
| 'row_cap';
export type FederationScopeDenyCode =
| 'invalid_scope'
| 'invalid_resource'
| 'resource_not_granted'
| 'resource_excluded'
| 'native_rbac_denied'
| 'invalid_limit';
export type FederationScopeDenyStatus = 400 | 403;
export interface FederationScopeDenyDetails {
readonly [key: string]: string | number | boolean | readonly string[];
}
export interface FederationScopeDenyReason {
readonly code: FederationScopeDenyCode;
readonly stage: FederationScopeDenyStage;
readonly statusCode: FederationScopeDenyStatus;
readonly message: string;
readonly grantId: string;
readonly peerId: string;
readonly subjectUserId: string;
readonly resource: string;
readonly details?: FederationScopeDenyDetails;
}
export interface FederationNativeRbacRequest {
readonly grantId: string;
readonly peerId: string;
readonly subjectUserId: string;
readonly resource: FederationResource;
}
export interface FederationNativeRbacAccess {
/** Whether this user may read personal rows for this resource. */
readonly includePersonal: boolean;
/** Team IDs this user may read for this resource under native RBAC. */
readonly teamIds: readonly string[];
}
export interface FederationNativeRbacAllowedResult {
readonly allowed: true;
readonly access: FederationNativeRbacAccess;
}
export interface FederationNativeRbacDeniedResult {
readonly allowed: false;
readonly reason?: string;
readonly details?: FederationScopeDenyDetails;
}
export type FederationNativeRbacResult =
| FederationNativeRbacAllowedResult
| FederationNativeRbacDeniedResult;
export interface FederationNativeRbacEvaluator {
evaluateReadAccess(request: FederationNativeRbacRequest): Promise<FederationNativeRbacResult>;
}
export interface FederationScopeEvaluationInput {
readonly context: FederationContext;
readonly resource: string;
readonly requestedLimit?: number;
readonly nativeRbac: FederationNativeRbacEvaluator;
}
export interface FederationScopeQueryFilter {
readonly resource: FederationResource;
readonly subjectUserId: string;
readonly includePersonal: boolean;
readonly teamIds: readonly string[];
readonly limit: number;
readonly maxRowsPerQuery: number;
}
export interface FederationScopeAllowedResult {
readonly allowed: true;
readonly filter: FederationScopeQueryFilter;
}
export interface FederationScopeDeniedResult {
readonly allowed: false;
readonly deny: FederationScopeDenyReason;
}
export type FederationScopeEvaluationResult =
| FederationScopeAllowedResult
| FederationScopeDeniedResult;
function isFederationResource(resource: string): resource is FederationResource {
return federationResourceSet.has(resource);
}
function uniqueStrings(values: readonly string[]): readonly string[] {
return Array.from(new Set<string>(values));
}
function intersectTeamIds(
nativeTeamIds: readonly string[],
scopedTeamIds: readonly string[] | undefined,
): readonly string[] {
const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds);
if (scopedTeamIds === undefined) {
return uniqueNativeTeamIds;
}
const nativeSet = new Set<string>(uniqueNativeTeamIds);
return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId));
}
function makeDenyReason(params: {
readonly code: FederationScopeDenyCode;
readonly stage: FederationScopeDenyStage;
readonly statusCode?: FederationScopeDenyStatus;
readonly message: string;
readonly context: FederationContext;
readonly resource: string;
readonly details?: FederationScopeDenyDetails;
}): FederationScopeDeniedResult {
return {
allowed: false,
deny: {
code: params.code,
stage: params.stage,
statusCode: params.statusCode ?? 403,
message: params.message,
grantId: params.context.grantId,
peerId: params.context.peerId,
subjectUserId: params.context.subjectUserId,
resource: params.resource,
...(params.details !== undefined ? { details: params.details } : {}),
},
};
}
@Injectable()
export class FederationScopeService {
async evaluateAccess(
input: FederationScopeEvaluationInput,
): Promise<FederationScopeEvaluationResult> {
const { context, resource, requestedLimit, nativeRbac } = input;
let scope: ReturnType<typeof parseFederationScope>;
try {
scope = parseFederationScope(context.scope);
} catch (error: unknown) {
const message =
error instanceof FederationScopeError
? 'Federation grant scope is invalid'
: 'Federation grant scope could not be parsed';
const details = error instanceof Error ? { reason: error.message } : undefined;
return makeDenyReason({
code: 'invalid_scope',
stage: 'scope_parse',
statusCode: 400,
message,
context,
resource,
...(details !== undefined ? { details } : {}),
});
}
if (!isFederationResource(resource)) {
return makeDenyReason({
code: 'invalid_resource',
stage: 'resource_allowlist',
message: 'Requested federation resource is not supported',
context,
resource,
details: { supportedResources: FEDERATION_RESOURCE_VALUES },
});
}
if (scope.excluded_resources.includes(resource)) {
return makeDenyReason({
code: 'resource_excluded',
stage: 'resource_exclusion',
message: 'Requested federation resource is explicitly excluded by grant scope',
context,
resource,
});
}
if (!scope.resources.includes(resource)) {
return makeDenyReason({
code: 'resource_not_granted',
stage: 'resource_allowlist',
message: 'Requested federation resource is not granted by scope',
context,
resource,
details: { grantedResources: scope.resources },
});
}
if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) {
return makeDenyReason({
code: 'invalid_limit',
stage: 'row_cap',
statusCode: 400,
message: 'Requested row limit must be a positive integer',
context,
resource,
details: { requestedLimit },
});
}
const nativeResult = await nativeRbac.evaluateReadAccess({
grantId: context.grantId,
peerId: context.peerId,
subjectUserId: context.subjectUserId,
resource,
});
if (!nativeResult.allowed) {
return makeDenyReason({
code: 'native_rbac_denied',
stage: 'native_rbac',
message: nativeResult.reason ?? 'Subject user is not allowed to read this resource',
context,
resource,
...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}),
});
}
const scopeFilter = scope.filters?.[resource];
const includePersonal =
Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal;
const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams);
const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query);
return {
allowed: true,
filter: {
resource,
subjectUserId: context.subjectUserId,
includePersonal,
teamIds,
limit,
maxRowsPerQuery: scope.max_rows_per_query,
},
};
}
}
@@ -0,0 +1,88 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import type { FastifyRequest } from 'fastify';
import { FederationCapabilitiesResponseSchema, FEDERATION_VERBS } from '@mosaicstack/types';
import { FederationScopeError } from '../../../scope-schema.js';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import { CapabilitiesController } from '../capabilities.controller.js';
const VALID_SCOPE = {
resources: ['tasks', 'notes'],
excluded_resources: ['credentials'],
max_rows_per_query: 250,
} as const;
const DEFAULTED_SCOPE = {
resources: ['memory'],
max_rows_per_query: 10,
} as const;
function makeRequest(scope: Record<string, unknown>): FastifyRequest {
return {
federationContext: {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope,
},
} as FastifyRequest;
}
describe('CapabilitiesController', () => {
it('declares GET /api/federation/v1/capabilities', () => {
expect(Reflect.getMetadata('path', CapabilitiesController)).toBe(
'api/federation/v1/capabilities',
);
expect(Reflect.getMetadata('path', CapabilitiesController.prototype.getCapabilities)).toBe('/');
expect(Reflect.getMetadata('method', CapabilitiesController.prototype.getCapabilities)).toBe(
RequestMethod.GET,
);
});
it('is protected only by FederationAuthGuard', () => {
const guards = Reflect.getMetadata('__guards__', CapabilitiesController) as unknown[];
expect(guards).toEqual([FederationAuthGuard]);
});
it('returns resources, excluded resources, max rows, and M3 supported verbs from the active grant scope', () => {
const controller = new CapabilitiesController();
const response = controller.getCapabilities(makeRequest(VALID_SCOPE));
expect(response).toEqual({
resources: ['tasks', 'notes'],
excluded_resources: ['credentials'],
max_rows_per_query: 250,
supported_verbs: [...FEDERATION_VERBS],
});
expect(FederationCapabilitiesResponseSchema.safeParse(response).success).toBe(true);
});
it('applies scope defaults without RBAC or resource filtering', () => {
const controller = new CapabilitiesController();
const response = controller.getCapabilities(makeRequest(DEFAULTED_SCOPE));
expect(response).toEqual({
resources: ['memory'],
excluded_resources: [],
max_rows_per_query: 10,
supported_verbs: ['list', 'get', 'capabilities'],
});
});
it('rejects invalid scope state instead of returning an invalid capabilities contract', () => {
const controller = new CapabilitiesController();
expect(() =>
controller.getCapabilities(
makeRequest({
resources: [],
max_rows_per_query: 0,
}),
),
).toThrow(FederationScopeError);
});
});
@@ -0,0 +1,428 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
createPgliteDb,
insights,
missionTasks,
missions,
preferences,
projects,
runPgliteMigrations,
teams,
users,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationListQueryService } from '../list-query.service.js';
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: [],
limit: 2,
maxRowsPerQuery: 2,
};
const SUBJECT_USER_ID = 'fed-m3-05-subject';
const OTHER_USER_ID = 'fed-m3-05-other';
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
const UNAUTHORIZED_TEAM_ID = '05000000-0000-4000-8000-000000000002';
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
const UNAUTHORIZED_PROJECT_ID = '05000000-0000-4000-8000-000000000103';
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
const UNAUTHORIZED_MISSION_ID = '05000000-0000-4000-8000-000000000203';
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
const SUBJECT_UNAUTHORIZED_NOTE_ID = '05000000-0000-4000-8000-000000000304';
const INSIGHT_ONE_ID = '05000000-0000-4000-8000-000000000401';
const INSIGHT_TWO_ID = '05000000-0000-4000-8000-000000000402';
const PREFERENCE_ONE_ID = '05000000-0000-4000-8000-000000000501';
const PREFERENCE_TWO_ID = '05000000-0000-4000-8000-000000000502';
let dbHandle: DbHandle | undefined;
function makeService() {
return new FederationListQueryService({} as Db);
}
function makeDbService() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
return new FederationListQueryService(dbHandle.db);
}
async function seedNotesFixture() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
await dbHandle.db.insert(users).values([
{
id: SUBJECT_USER_ID,
name: 'Federation Subject',
email: `${SUBJECT_USER_ID}@example.test`,
emailVerified: false,
},
{
id: OTHER_USER_ID,
name: 'Federation Other',
email: `${OTHER_USER_ID}@example.test`,
emailVerified: false,
},
]);
await dbHandle.db.insert(teams).values([
{
id: TEAM_ID,
name: 'FED-M3-05 Team',
slug: 'fed-m3-05-team',
ownerId: SUBJECT_USER_ID,
managerId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_TEAM_ID,
name: 'FED-M3-05 Unauthorized Team',
slug: 'fed-m3-05-unauthorized-team',
ownerId: OTHER_USER_ID,
managerId: OTHER_USER_ID,
},
]);
await dbHandle.db.insert(projects).values([
{
id: PERSONAL_PROJECT_ID,
name: 'FED-M3-05 Personal Project',
ownerId: SUBJECT_USER_ID,
ownerType: 'user',
},
{
id: TEAM_PROJECT_ID,
name: 'FED-M3-05 Team Project',
teamId: TEAM_ID,
ownerType: 'team',
},
{
id: UNAUTHORIZED_PROJECT_ID,
name: 'FED-M3-05 Unauthorized Project',
teamId: UNAUTHORIZED_TEAM_ID,
ownerType: 'team',
},
]);
await dbHandle.db.insert(missions).values([
{
id: PERSONAL_MISSION_ID,
name: 'FED-M3-05 Personal Mission',
projectId: PERSONAL_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: TEAM_MISSION_ID,
name: 'FED-M3-05 Team Mission',
projectId: TEAM_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_MISSION_ID,
name: 'FED-M3-05 Unauthorized Mission',
projectId: UNAUTHORIZED_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
]);
await dbHandle.db.insert(missionTasks).values([
{
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on team mission',
createdAt: new Date('2026-06-24T03:00:00.000Z'),
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
},
{
id: OTHER_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: OTHER_USER_ID,
notes: 'other user note on team mission',
createdAt: new Date('2026-06-24T02:00:00.000Z'),
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
},
{
id: SUBJECT_PERSONAL_NOTE_ID,
missionId: PERSONAL_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on personal mission',
createdAt: new Date('2026-06-24T01:00:00.000Z'),
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
},
{
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
missionId: UNAUTHORIZED_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note outside grant-visible missions',
createdAt: new Date('2026-06-24T04:00:00.000Z'),
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
},
]);
const memoryCreatedAt = new Date('2026-06-24T05:00:00.000Z');
await dbHandle.db.insert(insights).values([
{
id: INSIGHT_ONE_ID,
userId: SUBJECT_USER_ID,
content: 'first insight',
source: 'agent',
createdAt: memoryCreatedAt,
updatedAt: memoryCreatedAt,
},
{
id: INSIGHT_TWO_ID,
userId: SUBJECT_USER_ID,
content: 'second insight',
source: 'agent',
createdAt: memoryCreatedAt,
updatedAt: memoryCreatedAt,
},
]);
await dbHandle.db.insert(preferences).values([
{
id: PREFERENCE_ONE_ID,
userId: SUBJECT_USER_ID,
key: 'fed-m3-05-pref-1',
value: { enabled: true },
createdAt: memoryCreatedAt,
updatedAt: memoryCreatedAt,
},
{
id: PREFERENCE_TWO_ID,
userId: SUBJECT_USER_ID,
key: 'fed-m3-05-pref-2',
value: { enabled: false },
createdAt: memoryCreatedAt,
updatedAt: memoryCreatedAt,
},
]);
}
function stubRows(
service: FederationListQueryService,
...pages: Array<Array<Record<string, unknown>>>
) {
const mock = vi.fn();
for (const page of pages) {
mock.mockResolvedValueOnce(page);
}
(
service as unknown as {
listAllRows: (
_filter: FederationScopeQueryFilter,
_rowLimit: number,
_cursor: unknown,
) => Promise<Array<Record<string, unknown>>>;
}
).listAllRows = mock;
return mock;
}
describe('FederationListQueryService', () => {
beforeAll(async () => {
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
await runPgliteMigrations(dbHandle);
await seedNotesFixture();
});
afterAll(async () => {
await dbHandle?.close();
dbHandle = undefined;
});
it('denies sensitive resources in native RBAC for M3 list reads', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
}),
).resolves.toMatchObject({
allowed: false,
reason: 'credentials federation list access is not implemented in M3',
});
});
it('allows personal memory reads without requiring team lookup', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'memory',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
});
it('applies the scope row cap and returns an opaque next cursor when truncated', async () => {
const service = makeService();
const listAllRows = stubRows(
service,
[
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') },
],
[{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
);
const firstPage = await service.list({ filter: TASK_FILTER });
expect(firstPage).toEqual({
items: [
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
],
truncated: true,
nextCursor: expect.any(String),
});
expect(listAllRows).toHaveBeenNthCalledWith(1, TASK_FILTER, 3, undefined);
const secondPage = await service.list({ filter: TASK_FILTER, cursor: firstPage.nextCursor });
expect(secondPage).toEqual({
items: [{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
truncated: false,
});
expect(listAllRows).toHaveBeenNthCalledWith(
2,
TASK_FILTER,
3,
expect.objectContaining({ id: '2' }),
);
});
it('rejects invalid cursors instead of falling back to the first page', async () => {
const service = makeService();
stubRows(service, [{ id: '1' }]);
await expect(service.list({ filter: TASK_FILTER, cursor: 'not-base64-json' })).rejects.toThrow(
'Invalid federation list cursor',
);
});
it('throws when a truncated page cannot encode a resumable cursor', async () => {
const service = makeService();
stubRows(service, [
{ id: '2', createdAt: 'not-a-date' },
{ id: '1', createdAt: 'not-a-date' },
]);
await expect(service.list({ filter: { ...TASK_FILTER, limit: 1 } })).rejects.toThrow(
'Federation list cursor cannot be encoded',
);
});
it('throws on unsupported resources instead of crashing pagination', async () => {
const service = makeService();
await expect(
service.list({
filter: {
...TASK_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
},
}),
).rejects.toThrow('Unsupported federation list resource');
});
it('does not leak another user mission task notes through team-scoped note reads', async () => {
const service = makeDbService();
const result = await service.list({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 10,
maxRowsPerQuery: 10,
},
});
const ids = result.items.map((item) => item['id']);
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
});
it('does not return subject personal mission task notes when includePersonal is false', async () => {
const service = makeDbService();
const result = await service.list({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 10,
maxRowsPerQuery: 10,
},
});
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
});
it('does not return subject notes from missions outside the grant-visible project set', async () => {
const service = makeDbService();
const result = await service.list({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [TEAM_ID],
limit: 10,
maxRowsPerQuery: 10,
},
});
const ids = result.items.map((item) => item['id']);
expect(ids).toContain(SUBJECT_PERSONAL_NOTE_ID);
expect(ids).toContain(SUBJECT_TEAM_NOTE_ID);
expect(ids).not.toContain(SUBJECT_UNAUTHORIZED_NOTE_ID);
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
});
it('paginates memory deterministically across insights and preferences', async () => {
const service = makeDbService();
const filter: FederationScopeQueryFilter = {
resource: 'memory',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [],
limit: 2,
maxRowsPerQuery: 2,
};
const firstPage = await service.list({ filter });
const secondPage = await service.list({ filter, cursor: firstPage.nextCursor });
const firstPageIds = firstPage.items.map((item) => item['id']);
const secondPageIds = secondPage.items.map((item) => item['id']);
const allIds = [...firstPageIds, ...secondPageIds];
expect(firstPage).toMatchObject({ truncated: true, nextCursor: expect.any(String) });
expect(firstPageIds).toEqual([INSIGHT_TWO_ID, INSIGHT_ONE_ID]);
expect(secondPageIds).toEqual([PREFERENCE_TWO_ID, PREFERENCE_ONE_ID]);
expect(new Set(allIds).size).toBe(allIds.length);
});
});
@@ -0,0 +1,188 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import type {
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from '../../scope.service.js';
import { ListController } from '../list.controller.js';
import type { FederationListQueryResult } from '../list-query.service.js';
const FEDERATION_CONTEXT = {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope: { resources: ['tasks'], max_rows_per_query: 25 },
};
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: ['team-1'],
limit: 10,
maxRowsPerQuery: 25,
};
function makeRequest(): FastifyRequest {
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
}
function allowedScope(
filter: FederationScopeQueryFilter = TASK_FILTER,
): FederationScopeEvaluationResult {
return { allowed: true, filter };
}
function makeController(opts?: {
scopeResult?: FederationScopeEvaluationResult;
queryResult?: FederationListQueryResult;
}) {
const scope = {
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
};
const query = {
evaluateReadAccess: vi.fn(),
list: vi.fn().mockResolvedValue(
opts?.queryResult ?? {
items: [
{
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
},
],
truncated: false,
},
),
};
return {
controller: new ListController(scope as never, query as never),
scope,
query,
};
}
describe('ListController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('declares POST /api/federation/v1/list/:resource protected only by FederationAuthGuard', () => {
expect(Reflect.getMetadata('path', ListController)).toBe('api/federation/v1/list');
expect(Reflect.getMetadata('path', ListController.prototype.list)).toBe(':resource');
expect(Reflect.getMetadata('method', ListController.prototype.list)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__guards__', ListController)).toEqual([FederationAuthGuard]);
});
it('runs AuthGuard context through ScopeService and returns local-source tagged rows', async () => {
const { controller, scope, query } = makeController();
const response = await controller.list('tasks', makeRequest(), { limit: 10 });
expect(scope.evaluateAccess).toHaveBeenCalledWith({
context: FEDERATION_CONTEXT,
resource: 'tasks',
requestedLimit: 10,
nativeRbac: query,
});
expect(query.list).toHaveBeenCalledWith({ filter: TASK_FILTER, cursor: undefined });
expect(response).toEqual({
items: [
{
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
_source: 'local',
},
],
});
});
it('preserves pagination metadata when row cap truncates the query layer result', async () => {
const { controller } = makeController({
queryResult: {
items: [{ id: 'task-1' }],
nextCursor: 'cursor-2',
truncated: true,
},
});
const response = await controller.list('tasks', makeRequest(), { cursor: 'cursor-1' });
expect(response).toEqual({
items: [{ id: 'task-1', _source: 'local' }],
nextCursor: 'cursor-2',
_truncated: true,
});
});
it('returns a federation error envelope when auth guard context is missing', async () => {
const { controller, scope, query } = makeController();
await expect(
controller.list('tasks', {} as unknown as FastifyRequest, {}),
).rejects.toMatchObject({
response: {
error: {
code: 'unauthorized',
message: 'Federation context missing',
},
},
status: 401,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.list).not.toHaveBeenCalled();
});
it('returns a federation error envelope when scope evaluation denies access', async () => {
const { controller, query } = makeController({
scopeResult: {
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
message: 'Requested federation resource is explicitly excluded by grant scope',
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
},
},
});
await expect(controller.list('credentials', makeRequest(), {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is explicitly excluded by grant scope',
},
},
status: 403,
});
expect(query.list).not.toHaveBeenCalled();
});
it('rejects malformed request body fields before querying storage', async () => {
const { controller, scope, query } = makeController();
await expect(controller.list('tasks', makeRequest(), { cursor: 123 })).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
await expect(controller.list('tasks', makeRequest(), { limit: false })).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
await expect(controller.list('tasks', makeRequest(), { limit: 'abc' })).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.list).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,38 @@
/**
* Federation capabilities verb (FED-M3-07).
*
* Returns the read-only capability envelope for the active grant attached by
* FederationAuthGuard. This endpoint intentionally does not invoke native RBAC
* or ScopeService: an active grant is enough to ask what the grant allows.
*/
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FEDERATION_VERBS,
type FederationCapabilitiesResponse,
type FederationVerb,
} from '@mosaicstack/types';
import { parseFederationScope } from '../../scope-schema.js';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
@Controller('api/federation/v1/capabilities')
@UseGuards(FederationAuthGuard)
export class CapabilitiesController {
@Get()
getCapabilities(@Req() request: FastifyRequest): FederationCapabilitiesResponse {
if (!request.federationContext) {
throw new Error('Federation context missing after auth guard');
}
const scope = parseFederationScope(request.federationContext.scope);
return {
resources: [...scope.resources],
excluded_resources: [...scope.excluded_resources],
max_rows_per_query: scope.max_rows_per_query,
supported_verbs: [...FEDERATION_VERBS] satisfies FederationVerb[],
};
}
}
@@ -0,0 +1,408 @@
/**
* Federation list query layer (FED-M3-05).
*
* Read-only DB adapter used by ListController after FederationAuthGuard and
* FederationScopeService have established the subject user, allowed resource,
* native-RBAC intersection, and row cap. Audit writes are intentionally
* deferred to M4.
*/
import { Inject, Injectable } from '@nestjs/common';
import {
and,
desc,
eq,
inArray,
insights,
isNotNull,
lt,
missionTasks,
missions,
or,
preferences,
projects,
tasks,
teamMembers,
type Db,
} from '@mosaicstack/db';
import type {
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeQueryFilter,
} from '../scope.service.js';
import { DB } from '../../../database/database.module.js';
export interface FederationListQueryRequest {
readonly filter: FederationScopeQueryFilter;
readonly cursor?: string;
}
export interface FederationListQueryResult<T extends object = Record<string, unknown>> {
readonly items: T[];
readonly nextCursor?: string;
readonly truncated: boolean;
}
type CursorSource = 'insights' | 'preferences';
const CURSOR_SOURCE = Symbol('federationCursorSource');
type RowObject = Record<string, unknown> & { readonly [CURSOR_SOURCE]?: CursorSource };
interface KeysetCursor {
readonly createdAt: Date;
readonly id: string;
readonly source?: CursorSource;
}
function encodeCursor(row: RowObject): string {
const createdAt = row['createdAt'];
const id = row['id'];
if (!(createdAt instanceof Date) || typeof id !== 'string') {
throw new Error('Federation list cursor cannot be encoded');
}
const source = row[CURSOR_SOURCE];
return Buffer.from(
JSON.stringify({ createdAt: createdAt.toISOString(), id, ...(source ? { source } : {}) }),
'utf8',
).toString('base64url');
}
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
if (cursor === undefined) {
return undefined;
}
try {
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('cursor must be an object');
}
const { createdAt, id, source } = parsed as {
createdAt?: unknown;
id?: unknown;
source?: unknown;
};
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
throw new Error('cursor is missing createdAt or id');
}
if (source !== undefined && source !== 'insights' && source !== 'preferences') {
throw new Error('cursor source is invalid');
}
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) {
throw new Error('cursor createdAt is invalid');
}
return { createdAt: date, id, ...(source ? { source } : {}) };
} catch {
throw new Error('Invalid federation list cursor');
}
}
function paginate<T extends RowObject>(rows: T[], limit: number): FederationListQueryResult<T> {
const page = rows.slice(0, limit);
const hasMore = rows.length > limit;
const nextCursor = hasMore ? encodeCursor(page[page.length - 1] ?? {}) : undefined;
return {
items: page,
truncated: hasMore,
...(nextCursor !== undefined ? { nextCursor } : {}),
};
}
function markCursorSource<T extends RowObject>(row: T, source: CursorSource): T {
Object.defineProperty(row, CURSOR_SOURCE, {
value: source,
enumerable: false,
configurable: false,
});
return row;
}
function sortRows(rows: RowObject[]): RowObject[] {
return [...rows].sort((a, b) => {
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
const bTime = b['createdAt'] instanceof Date ? b['createdAt'].getTime() : 0;
if (aTime !== bTime) {
return bTime - aTime;
}
return String(b['id'] ?? '').localeCompare(String(a['id'] ?? ''));
});
}
@Injectable()
export class FederationListQueryService implements FederationNativeRbacEvaluator {
constructor(@Inject(DB) private readonly db: Db) {}
async evaluateReadAccess(
request: FederationNativeRbacRequest,
): Promise<FederationNativeRbacResult> {
if (request.resource === 'credentials' || request.resource === 'api_keys') {
return {
allowed: false,
reason: `${request.resource} federation list access is not implemented in M3`,
details: { resource: request.resource },
};
}
if (request.resource === 'memory') {
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
}
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
return { allowed: true, access: { includePersonal: true, teamIds } };
}
async list<T extends RowObject = RowObject>(
request: FederationListQueryRequest,
): Promise<FederationListQueryResult<T>> {
const cursor = decodeCursor(request.cursor);
const rows = await this.listAllRows(request.filter, request.filter.limit + 1, cursor);
return paginate(rows as T[], request.filter.limit);
}
private async listAllRows(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
switch (filter.resource) {
case 'tasks':
return this.listTasks(filter, rowLimit, cursor);
case 'notes':
return this.listNotes(filter, rowLimit, cursor);
case 'memory':
return this.listMemory(filter, rowLimit, cursor);
case 'credentials':
case 'api_keys':
return [];
default:
throw new Error(`Unsupported federation list resource: ${String(filter.resource)}`);
}
}
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
const rows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, subjectUserId));
return rows.map((row) => row.teamId);
}
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
const clauses = [];
if (filter.includePersonal) {
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
}
if (filter.teamIds.length > 0) {
clauses.push(
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
);
}
if (clauses.length === 0) {
return [];
}
const rows = await this.db
.select({ id: projects.id })
.from(projects)
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
return rows.map((row) => row.id);
}
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
if (projectIds.length === 0) {
return [];
}
const rows = await this.db
.select({ id: missions.id })
.from(missions)
.where(inArray(missions.projectId, [...projectIds]));
return rows.map((row) => row.id);
}
private async listTasks(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
const clauses = [];
if (projectIds.length > 0) {
clauses.push(inArray(tasks.projectId, projectIds));
}
if (missionIds.length > 0) {
clauses.push(inArray(tasks.missionId, missionIds));
}
if (clauses.length === 0) {
return [];
}
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
const cursorClause = cursor
? or(
lt(tasks.createdAt, cursor.createdAt),
and(eq(tasks.createdAt, cursor.createdAt), lt(tasks.id, cursor.id)),
)
: undefined;
const rows = await this.db
.select({
id: tasks.id,
title: tasks.title,
description: tasks.description,
status: tasks.status,
priority: tasks.priority,
projectId: tasks.projectId,
missionId: tasks.missionId,
assignee: tasks.assignee,
tags: tasks.tags,
dueDate: tasks.dueDate,
metadata: tasks.metadata,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
})
.from(tasks)
.where(and(scopeClause, cursorClause))
.orderBy(desc(tasks.createdAt), desc(tasks.id))
.limit(rowLimit);
return sortRows(rows as RowObject[]);
}
private async listNotes(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
if (missionIds.length === 0) {
return [];
}
// mission_tasks rows are user-scoped even when the mission belongs to a team.
// Team visibility can narrow the mission set, but it must never widen the
// query to other users' mission task notes.
const scopeClause = and(
eq(missionTasks.userId, filter.subjectUserId),
inArray(missionTasks.missionId, missionIds),
);
const cursorClause = cursor
? or(
lt(missionTasks.createdAt, cursor.createdAt),
and(eq(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.id, cursor.id)),
)
: undefined;
const rows = await this.db
.select({
id: missionTasks.id,
missionId: missionTasks.missionId,
taskId: missionTasks.taskId,
status: missionTasks.status,
content: missionTasks.notes,
createdAt: missionTasks.createdAt,
updatedAt: missionTasks.updatedAt,
})
.from(missionTasks)
.where(and(scopeClause, cursorClause, isNotNull(missionTasks.notes)))
.orderBy(desc(missionTasks.createdAt), desc(missionTasks.id))
.limit(rowLimit);
return sortRows(rows.filter((row) => row.content !== '') as RowObject[]);
}
private async listMemory(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
if (!filter.includePersonal) {
return [];
}
if (cursor && cursor.source === undefined) {
throw new Error('Invalid federation list cursor');
}
const rows: RowObject[] = [];
// Memory spans two physical tables. To keep pagination deterministic and
// resumable without a SQL UNION, M3 emits a fixed block order: all insights
// first, then preferences. The opaque cursor records which table produced
// the boundary row, so the next page never re-applies one table's keyset to
// the other table (which could duplicate/skip rows at equal timestamps).
if (cursor?.source !== 'preferences') {
const insightCursorClause = cursor
? or(
lt(insights.createdAt, cursor.createdAt),
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
)
: undefined;
const insightRows = await this.db
.select({
id: insights.id,
kind: insights.source,
content: insights.content,
category: insights.category,
relevanceScore: insights.relevanceScore,
metadata: insights.metadata,
createdAt: insights.createdAt,
updatedAt: insights.updatedAt,
})
.from(insights)
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
.orderBy(desc(insights.createdAt), desc(insights.id))
.limit(rowLimit);
rows.push(...(insightRows as RowObject[]).map((row) => markCursorSource(row, 'insights')));
}
const remaining = rowLimit - rows.length;
if (remaining <= 0) {
return rows;
}
const preferenceCursorClause =
cursor?.source === 'preferences'
? or(
lt(preferences.createdAt, cursor.createdAt),
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
)
: undefined;
const preferenceRows = await this.db
.select({
id: preferences.id,
kind: preferences.category,
key: preferences.key,
value: preferences.value,
source: preferences.source,
mutable: preferences.mutable,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
})
.from(preferences)
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
.orderBy(desc(preferences.createdAt), desc(preferences.id))
.limit(remaining);
rows.push(
...(preferenceRows as RowObject[]).map((row) => markCursorSource(row, 'preferences')),
);
return rows;
}
}
@@ -0,0 +1,147 @@
/**
* Federation list verb (FED-M3-05).
*
* POST /api/federation/v1/list/:resource
*
* Pipeline: FederationAuthGuard attaches the active grant context, then
* FederationScopeService enforces grant scope + native RBAC intersection, then
* the read-only query layer returns capped rows tagged with `_source`. Read
* audit-log writes are deferred to M4; this controller does not persist request
* or response bodies.
*/
import {
Body,
Controller,
HttpException,
Inject,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FederationInvalidRequestError,
FederationScopeViolationError,
FederationUnauthorizedError,
SOURCE_LOCAL,
tagWithSource,
type FederationListResponse,
type SourceTag,
} from '@mosaicstack/types';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
import { FederationScopeService } from '../scope.service.js';
import { FederationListQueryService } from './list-query.service.js';
interface FederationListRequestBody {
readonly limit?: unknown;
readonly cursor?: unknown;
}
type FederatedRow = Record<string, unknown> & SourceTag;
function parseLimit(body: FederationListRequestBody | undefined): number | undefined {
if (body?.limit === undefined) {
return undefined;
}
const parsed =
typeof body.limit === 'number'
? body.limit
: typeof body.limit === 'string' && body.limit.trim().length > 0
? Number(body.limit)
: Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new HttpException(
new FederationInvalidRequestError(
'Federation list limit must be a positive integer',
).toEnvelope(),
400,
);
}
return parsed;
}
function parseCursor(body: FederationListRequestBody | undefined): string | undefined {
if (body?.cursor === undefined) {
return undefined;
}
if (typeof body.cursor === 'string') {
return body.cursor;
}
throw new HttpException(
new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(),
400,
);
}
@Controller('api/federation/v1/list')
@UseGuards(FederationAuthGuard)
export class ListController {
constructor(
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
@Inject(FederationListQueryService) private readonly query: FederationListQueryService,
) {}
@Post(':resource')
async list(
@Param('resource') resource: string,
@Req() request: FastifyRequest,
@Body() body?: FederationListRequestBody,
): Promise<FederationListResponse<FederatedRow>> {
if (!request.federationContext) {
throw new HttpException(
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
401,
);
}
const requestedLimit = parseLimit(body);
const cursor = parseCursor(body);
const scopeResult = await this.scope.evaluateAccess({
context: request.federationContext,
resource,
requestedLimit,
nativeRbac: this.query,
});
if (!scopeResult.allowed) {
const ErrorClass =
scopeResult.deny.statusCode === 400
? FederationInvalidRequestError
: FederationScopeViolationError;
throw new HttpException(
new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(),
scopeResult.deny.statusCode,
);
}
let result: Awaited<ReturnType<FederationListQueryService['list']>>;
try {
result = await this.query.list({ filter: scopeResult.filter, cursor });
} catch (error: unknown) {
if (error instanceof Error && error.message === 'Invalid federation list cursor') {
throw new HttpException(
new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(),
400,
);
}
throw error;
}
const response: FederationListResponse<FederatedRow> = {
items: tagWithSource(result.items, SOURCE_LOCAL),
};
if (result.nextCursor !== undefined) {
response.nextCursor = result.nextCursor;
}
if (result.truncated) {
response._truncated = true;
}
return response;
}
}
+72 -43
View File
@@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { SessionGCService } from './session-gc.service.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
type MockRedis = {
scan: ReturnType<typeof vi.fn>;
@@ -12,7 +13,12 @@ type MockRedis = {
describe('SessionGCService', () => {
let service: SessionGCService;
let mockRedis: MockRedis;
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
let mockLogService: {
logs: {
promoteSessionToWarm: ReturnType<typeof vi.fn>;
promoteToWarm: ReturnType<typeof vi.fn>;
};
};
/**
* Helper: build a scan mock that returns all provided keys in a single
@@ -30,6 +36,7 @@ describe('SessionGCService', () => {
mockLogService = {
logs: {
promoteSessionToWarm: vi.fn().mockResolvedValue(0),
promoteToWarm: vi.fn().mockResolvedValue(0),
},
};
@@ -59,54 +66,76 @@ describe('SessionGCService', () => {
expect(result.cleaned.valkeyKeys).toBeUndefined();
});
it('escapes glob metacharacters in a session identifier', async () => {
await service.collect('abc*?[tenant]\\escape');
expect(mockRedis.scan).toHaveBeenCalledWith(
'0',
'MATCH',
'mosaic:session:abc\\*\\?\\[tenant\\]\\\\escape:*',
'COUNT',
100,
);
});
it('preserves a valid durable approval after session GC', async () => {
const entries = new Map<string, string>();
const redis = {
scan: vi.fn().mockResolvedValue(['0', ['mosaic:session:owned:state']]),
get: vi.fn(async (key: string) => entries.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => entries.set(key, value)),
del: vi.fn(async (...keys: string[]) => {
let deleted = 0;
for (const key of keys) deleted += Number(entries.delete(key));
return deleted;
}),
};
const authorization = new CommandAuthorizationService(
{
select: () => ({
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
}),
} as never,
redis,
);
const command = {
name: 'gc',
description: 'System-wide garbage collection',
aliases: [],
scope: 'admin',
execution: 'socket',
available: true,
} as never;
const payload = { command: 'gc', conversationId: 'owned' };
const approval = await authorization.createApproval(command, payload, 'admin-1');
const approvalKey = `interaction:command-approval:${approval!.approvalId}`;
const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService);
await gc.collect('owned');
expect(entries.has(approvalKey)).toBe(true);
await expect(
authorization.authorize(command, payload, 'admin-1', approval!.approvalId),
).resolves.toEqual({ allowed: true });
});
it('collect() returns sessionId in result', async () => {
const result = await service.collect('test-session-id');
expect(result.sessionId).toBe('test-session-id');
});
it('fullCollect() deletes all session keys', async () => {
mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
const result = await service.fullCollect();
expect(mockRedis.del).toHaveBeenCalled();
expect(result.valkeyKeys).toBe(2);
it('collect() demotes logs only for the requested session', async () => {
await service.collect('owned-session');
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
'owned-session',
expect.any(Date),
);
expect(mockLogService.logs.promoteToWarm).not.toHaveBeenCalled();
});
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
mockRedis.scan = makeScanMock([]);
const result = await service.fullCollect();
expect(result.valkeyKeys).toBe(0);
expect(mockRedis.del).not.toHaveBeenCalled();
});
it('fullCollect() returns duration', async () => {
const result = await service.fullCollect();
expect(result.duration).toBeGreaterThanOrEqual(0);
});
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
// First scan call returns the global session list; subsequent calls return
// per-session keys during collect().
mockRedis.scan = vi
.fn()
.mockResolvedValueOnce([
'0',
['mosaic:session:abc:system', 'mosaic:session:abc:messages', 'mosaic:session:xyz:system'],
])
// collect('abc') scan
.mockResolvedValueOnce(['0', ['mosaic:session:abc:system', 'mosaic:session:abc:messages']])
// collect('xyz') scan
.mockResolvedValueOnce(['0', ['mosaic:session:xyz:system']]);
mockRedis.del.mockResolvedValue(1);
const result = await service.sweepOrphans();
expect(result.orphanedSessions).toBeGreaterThanOrEqual(0);
expect(result.duration).toBeGreaterThanOrEqual(0);
});
it('sweepOrphans() returns empty when no session keys', async () => {
mockRedis.scan = makeScanMock([]);
const result = await service.sweepOrphans();
expect(result.orphanedSessions).toBe(0);
expect(result.totalCleaned).toHaveLength(0);
it('does not expose automatic global GC entry points', () => {
expect('fullCollect' in service).toBe(false);
expect('sweepOrphans' in service).toBe(false);
});
});
+9 -106
View File
@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -13,49 +13,18 @@ export interface GCResult {
};
}
export interface GCSweepResult {
orphanedSessions: number;
totalCleaned: GCResult[];
duration: number;
}
export interface FullGCResult {
valkeyKeys: number;
logsDemoted: number;
jobsPurged: number;
tempFilesRemoved: number;
duration: number;
/** Escape Redis glob metacharacters so a session identifier is always literal. */
function escapeRedisGlobLiteral(value: string): string {
return value.replace(/[\\*?\[\]]/g, '\\$&');
}
@Injectable()
export class SessionGCService implements OnModuleInit {
private readonly logger = new Logger(SessionGCService.name);
export class SessionGCService {
constructor(
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
@Inject(LOG_SERVICE) private readonly logService: LogService,
) {}
onModuleInit(): void {
// Fire-and-forget: run full GC asynchronously so it does not block the
// NestJS bootstrap chain. Cold-start GC typically takes 100500 ms
// depending on Valkey key count; deferring it removes that latency from
// the TTFB of the first HTTP request.
this.fullCollect()
.then((result) => {
this.logger.log(
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
`${result.logsDemoted} logs demoted, ` +
`${result.jobsPurged} jobs purged, ` +
`${result.tempFilesRemoved} temp dirs removed ` +
`(${result.duration}ms)`,
);
})
.catch((err: unknown) => {
this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err));
});
}
/**
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan
@@ -79,86 +48,20 @@ export class SessionGCService implements OnModuleInit {
const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys
const pattern = `mosaic:session:${sessionId}:*`;
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
// 2. PG: demote hot-tier agent_logs for this session to warm
const cutoff = new Date(); // demote all hot logs for this session
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
// 2. PG: demote hot-tier agent logs for this session only.
const cutoff = new Date();
const logsDemoted = await this.logService.logs.promoteSessionToWarm(sessionId, cutoff);
if (logsDemoted > 0) {
result.cleaned.logsDemoted = logsDemoted;
}
return result;
}
/**
* Sweep GC — find orphaned artifacts from dead sessions.
* System-wide operation: only call from admin-authorized paths or internal
* scheduled jobs. Individual session cleanup is handled by collect().
*/
async sweepOrphans(): Promise<GCSweepResult> {
const start = Date.now();
const cleaned: GCResult[] = [];
// 1. Find all session-scoped Valkey keys (non-blocking SCAN)
const allSessionKeys = await this.scanKeys('mosaic:session:*');
// Extract unique session IDs from keys
const sessionIds = new Set<string>();
for (const key of allSessionKeys) {
const match = key.match(/^mosaic:session:([^:]+):/);
if (match) sessionIds.add(match[1]!);
}
// 2. For each session ID, collect stale keys
for (const sessionId of sessionIds) {
const gcResult = await this.collect(sessionId);
if (Object.keys(gcResult.cleaned).length > 0) {
cleaned.push(gcResult);
}
}
return {
orphanedSessions: cleaned.length,
totalCleaned: cleaned,
duration: Date.now() - start,
};
}
/**
* Full GC — aggressive collection for cold start.
* Assumes no sessions survived the restart.
*/
async fullCollect(): Promise<FullGCResult> {
const start = Date.now();
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
const sessionKeys = await this.scanKeys('mosaic:session:*');
if (sessionKeys.length > 0) {
await this.redis.del(...sessionKeys);
}
// 2. NOTE: channel keys are NOT collected on cold start
// (discord/telegram plugins may reconnect and resume)
// 3. PG: demote stale hot-tier logs older than 24h to warm
const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff);
// 4. No summarization job purge API available yet
const jobsPurged = 0;
return {
valkeyKeys: sessionKeys.length,
logsDemoted,
jobsPurged,
tempFilesRemoved: 0,
duration: Date.now() - start,
};
}
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { HealthController } from './health.controller.js';
describe('HealthController', (): void => {
it('exposes liveness and readiness without configuration details', (): void => {
const controller = new HealthController();
expect(controller.check()).toEqual({ status: 'ok' });
expect(controller.ready()).toEqual({ status: 'ready' });
expect(JSON.stringify(controller.ready())).not.toContain('credential');
});
});
@@ -6,4 +6,10 @@ export class HealthController {
check(): { status: string } {
return { status: 'ok' };
}
/** Readiness intentionally exposes no configuration, provider, or credential details. */
@Get('ready')
ready(): { status: string } {
return { status: 'ready' };
}
}
+5 -11
View File
@@ -6,11 +6,10 @@ import {
type OnModuleDestroy,
} from '@nestjs/common';
import { SummarizationService } from './summarization.service.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import {
QueueService,
QUEUE_SUMMARIZATION,
QUEUE_GC,
QUEUE_SUMMARIZATION,
QUEUE_TIER_MANAGEMENT,
} from '../queue/queue.service.js';
import type { Worker } from 'bullmq';
@@ -23,14 +22,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
@Inject(QueueService) private readonly queueService: QueueService,
) {}
async onModuleInit(): Promise<void> {
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
// M6-003: Summarization repeatable job
await this.queueService.addRepeatableJob(
@@ -56,15 +53,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
});
this.registeredWorkers.push(tierWorker);
// M6-004: GC repeatable job
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
await this.sessionGC.sweepOrphans();
});
this.registeredWorkers.push(gcWorker);
// Retire any repeatable global GC schedule created by older deployments.
// Session cleanup is now triggered only by an authorized session lifecycle operation.
await this.queueService.removeRepeatableJobs(QUEUE_GC, 'session-gc');
this.logger.log(
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
);
}
+22 -5
View File
@@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common';
import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaicstack/auth';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import type { McpService } from './mcp.service.js';
import {
createMcpActorContext,
deriveMcpToolScopesForUser,
type McpService,
} from './mcp.service.js';
import { AUTH } from '../auth/auth.tokens.js';
/**
@@ -67,14 +71,25 @@ async function handleMcpRequest(
return;
}
const userId = result.user.id;
const authUser = result.user as {
id: string;
role?: string | null;
tenantId?: string | null;
organizationId?: string | null;
};
const actor = createMcpActorContext({
userId: authUser.id,
role: authUser.role,
tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined,
scopes: deriveMcpToolScopesForUser({ role: authUser.role }),
});
// ─── Session routing ─────────────────────────────────────────────────────
const sessionId = req.raw.headers['mcp-session-id'];
if (typeof sessionId === 'string' && sessionId.length > 0) {
// Existing session request
const transport = mcpService.getSession(sessionId);
const transport = mcpService.getSession(sessionId, actor);
if (!transport) {
logger.warn(`MCP session not found: ${sessionId}`);
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
@@ -112,8 +127,10 @@ async function handleMcpRequest(
}
// Create new session and handle this initializing request
const { transport } = mcpService.createSession(userId);
logger.log(`New MCP session created for user ${userId}`);
const { transport } = mcpService.createSession(actor);
logger.log(
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
);
await transport.handleRequest(req.raw, reply.raw, body);
}
+461
View File
@@ -0,0 +1,461 @@
import { describe, expect, it, vi } from 'vitest';
import type { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Brain } from '@mosaicstack/brain';
import type { Memory } from '@mosaicstack/memory';
import type { EmbeddingService } from '../memory/embedding.service.js';
import type { CoordService } from '../coord/coord.service.js';
import {
assertMcpToolAuthorized,
createMcpActorContext,
deriveMcpToolScopesForUser,
MCP_TOOL_SCOPES,
McpService,
type McpToolName,
} from './mcp.service.js';
type ToolResult = { content: Array<{ type: 'text'; text: string }> };
type ToolHandler = (params: Record<string, unknown>) => Promise<ToolResult>;
interface CapturedTool {
inputSchema: z.ZodType;
handler: ToolHandler;
}
function makeCapturingServer(): { server: McpServer; tools: Map<string, CapturedTool> } {
const tools = new Map<string, CapturedTool>();
const server = {
registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void {
tools.set(name, { inputSchema: config.inputSchema, handler });
},
};
return { server: server as unknown as McpServer, tools };
}
function makeService(opts?: {
projects?: Array<Record<string, unknown> & { id: string; ownerId?: string | null }>;
missions?: Array<
Record<string, unknown> & { id: string; projectId?: string | null; userId?: string | null }
>;
tasks?: Array<
Record<string, unknown> & {
id: string;
projectId?: string | null;
missionId?: string | null;
status?: string;
}
>;
}) {
const projects = opts?.projects ?? [];
const missions = opts?.missions ?? [];
const tasks = opts?.tasks ?? [];
const brain = {
projects: {
findAll: vi.fn(async () => projects),
findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null),
},
tasks: {
findAll: vi.fn(async () => tasks),
findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
findByProject: vi.fn(async (projectId: string) =>
tasks.filter((task) => task.projectId === projectId),
),
findByMission: vi.fn(async (missionId: string) =>
tasks.filter((task) => task.missionId === missionId),
),
findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)),
create: vi.fn(async (task: Record<string, unknown>) => ({ id: 'task-1', ...task })),
update: vi.fn(async (id: string, updates: Record<string, unknown>) => ({ id, ...updates })),
},
missions: {
findAll: vi.fn(async () => missions),
findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null),
findByProject: vi.fn(async (projectId: string) =>
missions.filter((mission) => mission.projectId === projectId),
),
},
conversations: {
findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]),
},
} as unknown as Brain;
const memory = {
insights: {
searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]),
create: vi.fn(async (insight: Record<string, unknown>) => ({ id: 'insight-2', ...insight })),
},
preferences: {
findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]),
findByUserAndCategory: vi.fn(async (userId: string, category: string) => [
{ key: 'theme', userId, category },
]),
upsert: vi.fn(async (preference: Record<string, unknown>) => ({
id: 'pref-1',
...preference,
})),
},
} as unknown as Memory;
const embeddings = {
available: true,
embed: vi.fn(async () => [0.1, 0.2, 0.3]),
} as unknown as EmbeddingService;
const coord = {
getMissionStatus: vi.fn(async () => null),
listTasks: vi.fn(async () => []),
getTaskStatus: vi.fn(async () => null),
} as unknown as CoordService;
return {
service: new McpService(brain, memory, embeddings, coord),
brain: brain as unknown as {
conversations: { findAll: ReturnType<typeof vi.fn> };
tasks: {
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
},
memory: memory as unknown as {
insights: { searchByEmbedding: ReturnType<typeof vi.fn> };
},
coord: coord as unknown as {
listTasks: ReturnType<typeof vi.fn>;
},
};
}
function getTool(tools: Map<string, CapturedTool>, name: McpToolName): CapturedTool {
const tool = tools.get(name);
if (!tool) throw new Error(`Missing captured tool ${name}`);
return tool;
}
function makeMemberActor(userId = 'authenticated-user') {
return createMcpActorContext({
userId,
role: 'member',
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
});
}
function makeAdminActor(userId = 'admin-user', tenantId?: string) {
return createMcpActorContext({
userId,
tenantId,
role: 'admin',
scopes: deriveMcpToolScopesForUser({ role: 'admin' }),
});
}
function makePlatformAdminActor(userId = 'platform-admin-user') {
return createMcpActorContext({
userId,
role: 'platform-admin',
scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }),
});
}
describe('MCP actor identity and tool scope enforcement', () => {
it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => {
const actor = createMcpActorContext({
userId: ' user-authenticated ',
role: 'member',
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
});
expect(actor.userId).toBe('user-authenticated');
expect(actor.tenantId).toBe('user:user-authenticated');
expect(actor.role).toBe('member');
expect(actor.channel).toBe('mcp');
expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i);
expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true);
expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(true);
});
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
const actor = createMcpActorContext({ userId: 'user-authenticated' });
expect(actor.scopes.size).toBe(0);
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
'MCP tool scope denied: memory:insight:read',
);
});
it('fails closed when a tool caller supplies actor or tenant identity fields', () => {
const actor = createMcpActorContext({ userId: 'user-authenticated' });
expect(() =>
assertMcpToolAuthorized(actor, 'memory_search', {
userId: 'victim-user',
query: 'private data',
}),
).toThrow('MCP caller-controlled identity field is forbidden: userId');
expect(() =>
assertMcpToolAuthorized(actor, 'coord_list_tasks', {
tenantId: 'victim-tenant',
projectPath: '/tmp/project',
}),
).toThrow('MCP caller-controlled identity field is forbidden: tenantId');
expect(() =>
assertMcpToolAuthorized(actor, 'brain_create_task', {
title: 'forged org',
organizationId: 'victim-org',
}),
).toThrow('MCP caller-controlled identity field is forbidden: organizationId');
expect(() =>
assertMcpToolAuthorized(actor, 'brain_update_task', {
title: 'forged team',
teamId: 'victim-team',
}),
).toThrow('MCP caller-controlled identity field is forbidden: teamId');
});
it('fails closed when the server-derived actor lacks the required per-tool scope', () => {
const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] });
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
'MCP tool scope denied: memory:insight:read',
);
});
it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => {
const { service, memory } = makeService();
const { server, tools } = makeCapturingServer();
const actor = makeMemberActor('authenticated-user');
service.registerTools(server, actor);
const tool = getTool(tools, 'memory_search');
expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe(
false,
);
await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow(
'MCP caller-controlled identity field is forbidden: userId',
);
expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled();
await tool.handler({ query: 'only my notes' });
expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith(
'authenticated-user',
[0.1, 0.2, 0.3],
5,
);
});
it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => {
const { service, brain } = makeService();
const { server, tools } = makeCapturingServer();
const actor = makeMemberActor('authenticated-user');
service.registerTools(server, actor);
const tool = getTool(tools, 'brain_list_conversations');
expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false);
await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow(
'MCP caller-controlled identity field is forbidden: userId',
);
expect(brain.conversations.findAll).not.toHaveBeenCalled();
await tool.handler({});
expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user');
});
it('scopes brain project, mission, and task reads to the authenticated actor', async () => {
const { service } = makeService({
projects: [
{ id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' },
{ id: 'project-victim', ownerId: 'victim-user', name: 'victim' },
],
missions: [
{ id: 'mission-owned', projectId: 'project-owned' },
{ id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' },
],
tasks: [
{ id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' },
{ id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' },
{ id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' },
{ id: 'task-unowned', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeMemberActor('authenticated-user');
service.registerTools(server, actor);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual([
'task-owned-project',
'task-owned-mission',
]);
});
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
const { service } = makeService({
projects: [
{
id: 'project-tenant-a',
ownerId: 'other-user-a',
teamId: 'tenant-a',
name: 'same tenant',
},
{
id: 'project-tenant-b',
ownerId: 'other-user-b',
teamId: 'tenant-b',
name: 'other tenant',
},
],
missions: [
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, actor);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
});
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
const { service, brain } = makeService({
projects: [
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
],
missions: [
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, actor);
await expect(
getTool(tools, 'brain_create_task').handler({
title: 'unscoped tenant write',
}),
).rejects.toThrow('MCP task scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
await expect(
getTool(tools, 'brain_create_task').handler({
title: 'cross-tenant write',
projectId: 'project-tenant-b',
}),
).rejects.toThrow('MCP task project scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
await expect(
getTool(tools, 'brain_update_task').handler({
id: 'task-tenant-a',
projectId: 'project-tenant-b',
}),
).rejects.toThrow('MCP task project scope denied');
expect(brain.tasks.update).not.toHaveBeenCalled();
const updateResult = await getTool(tools, 'brain_update_task').handler({
id: 'task-tenant-b',
title: 'cross-tenant update',
});
expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b');
expect(brain.tasks.update).not.toHaveBeenCalled();
await getTool(tools, 'brain_create_task').handler({
title: 'same-tenant write',
projectId: 'project-tenant-a',
});
expect(brain.tasks.create).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }),
);
});
it('keeps admin-only coordination tools on server-derived paths', async () => {
const { service, coord } = makeService();
const { server, tools } = makeCapturingServer();
const member = makeMemberActor('authenticated-user');
const tenantAdmin = makeAdminActor('admin-user');
const platformAdmin = makePlatformAdminActor('platform-admin-user');
service.registerTools(server, member);
const memberTool = getTool(tools, 'coord_list_tasks');
expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false);
await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
tools.clear();
service.registerTools(server, tenantAdmin);
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
tools.clear();
service.registerTools(server, platformAdmin);
const platformAdminTool = getTool(tools, 'coord_list_tasks');
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
});
it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => {
const { service } = makeService();
const owner = makeMemberActor('owner-user');
const attacker = makeMemberActor('attacker-user');
const admin = makeAdminActor('admin-user');
const downgradedAdmin = makeMemberActor('admin-user');
const { sessionId, transport } = service.createSession(owner);
const staleSession = service.createSession(admin);
expect(service.getSession(sessionId, owner)).toBe(transport);
expect(service.getSession(sessionId, attacker)).toBeNull();
expect(service.getSession(sessionId, owner)).toBe(transport);
expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull();
expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport);
await service.onModuleDestroy();
});
});
+399 -78
View File
@@ -10,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js';
import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.service.js';
export const MCP_CALLER_IDENTITY_FIELDS = [
'actorId',
'actor',
'authenticatedUserId',
'channel',
'organizationId',
'ownerId',
'sessionUserId',
'teamId',
'tenant',
'tenantId',
'user',
'userId',
] as const;
type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number];
export const MCP_TOOL_SCOPES = {
brain_list_projects: 'brain:project:read',
brain_get_project: 'brain:project:read',
brain_list_tasks: 'brain:task:read',
brain_create_task: 'brain:task:write',
brain_update_task: 'brain:task:write',
brain_list_missions: 'brain:mission:read',
brain_list_conversations: 'brain:conversation:read',
memory_search: 'memory:insight:read',
memory_get_preferences: 'memory:preference:read',
memory_save_preference: 'memory:preference:write',
memory_save_insight: 'memory:insight:write',
coord_mission_status: 'coord:read',
coord_list_tasks: 'coord:read',
coord_task_detail: 'coord:read',
} as const;
export type McpToolName = keyof typeof MCP_TOOL_SCOPES;
export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName];
export interface McpActorContext {
userId: string;
tenantId: string;
role: string;
channel: 'mcp';
correlationId: string;
scopes: ReadonlySet<McpToolScope>;
}
interface SessionEntry {
server: McpServer;
transport: StreamableHTTPServerTransport;
createdAt: Date;
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
export function deriveMcpToolScopesForUser(input: {
role?: string | null;
}): ReadonlySet<McpToolScope> {
if (input.role === 'platform-admin' || input.role === 'super-admin') {
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
}
if (input.role === 'admin') {
return new Set(TENANT_ADMIN_MCP_SCOPES);
}
return new Set(MEMBER_MCP_SCOPES);
}
export function createMcpActorContext(input: {
userId: string;
tenantId?: string;
role?: string | null;
scopes?: Iterable<McpToolScope>;
correlationId?: string;
}): McpActorContext {
const userId = input.userId.trim();
if (userId.length === 0) {
throw new Error('MCP authenticated user is required');
}
return {
userId,
tenantId: input.tenantId?.trim() || `user:${userId}`,
role: input.role ?? 'member',
channel: 'mcp',
correlationId: input.correlationId ?? randomUUID(),
scopes: new Set(input.scopes ?? []),
};
}
export function assertNoCallerControlledIdentity(params: unknown): void {
if (params === null || typeof params !== 'object') return;
const keys = new Set(Object.keys(params));
const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) =>
keys.has(field),
);
if (forbidden) {
throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`);
}
}
export function assertMcpToolAuthorized(
actor: McpActorContext,
toolName: McpToolName,
params: unknown,
): void {
assertNoCallerControlledIdentity(params);
const requiredScope = MCP_TOOL_SCOPES[toolName];
if (!actor.scopes.has(requiredScope)) {
throw new Error(`MCP tool scope denied: ${requiredScope}`);
}
}
function strictObject<T extends z.ZodRawShape>(shape: T): z.ZodObject<T> {
return z.object(shape).strict();
}
type TenantScopedLike = {
tenantId?: string | null;
organizationId?: string | null;
teamId?: string | null;
};
type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null };
type MissionLike = TenantScopedLike & {
id: string;
projectId?: string | null;
userId?: string | null;
};
type TaskLike = TenantScopedLike & {
projectId?: string | null;
missionId?: string | null;
userId?: string | null;
};
function isGlobalAdminActor(actor: McpActorContext): boolean {
return actor.role === 'platform-admin' || actor.role === 'super-admin';
}
function isTenantAdminActor(actor: McpActorContext): boolean {
return actor.role === 'admin';
}
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
return (
record.tenantId === actor.tenantId ||
record.organizationId === actor.tenantId ||
record.teamId === actor.tenantId
);
}
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
if (isGlobalAdminActor(actor)) return projects;
return projects.filter(
(project) =>
project.ownerId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
);
}
function filterMissionsByDirectActorScope<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): T[] {
if (isGlobalAdminActor(actor)) return missions;
return missions.filter(
(mission) =>
mission.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
);
}
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
if (left.size !== right.size) return false;
for (const scope of left) {
if (!right.has(scope)) return false;
}
return true;
}
function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean {
return (
stored.userId === current.userId &&
stored.tenantId === current.tenantId &&
stored.role === current.role &&
scopesEqual(stored.scopes, current.scopes)
);
}
@Injectable()
@@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy {
* Creates a new MCP session with its own server + transport pair.
* Returns the transport for use by the controller.
*/
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
createSession(actor: McpActorContext): {
sessionId: string;
transport: StreamableHTTPServerTransport;
} {
const sessionId = randomUUID();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
onsessioninitialized: (id) => {
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
this.logger.log(
`MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
);
},
});
@@ -48,7 +258,7 @@ export class McpService implements OnModuleDestroy {
{ capabilities: { tools: {} } },
);
this.registerTools(server, userId);
this.registerTools(server, actor);
transport.onclose = () => {
this.logger.log(`MCP session closed: ${sessionId}`);
@@ -61,31 +271,126 @@ export class McpService implements OnModuleDestroy {
);
});
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor });
return { sessionId, transport };
}
/**
* Returns the transport for an existing session, or null if not found.
* Returns the transport for an existing session only when it belongs to the
* currently authenticated MCP actor. Guessed or cross-tenant session IDs grant
* no authority.
*/
getSession(sessionId: string): StreamableHTTPServerTransport | null {
return this.sessions.get(sessionId)?.transport ?? null;
getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
const entry = this.sessions.get(sessionId);
if (!entry) return null;
if (!sameActorAuthorization(entry.actor, actor)) {
this.logger.warn(
`MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`,
);
return null;
}
return entry.transport;
}
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
}
private async filterMissionsForActor<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return missions;
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
const projectIds = new Set(
filterProjectsForActor(actor, projects).map((project) => project.id),
);
return missions.filter(
(mission) =>
filterMissionsByDirectActorScope(actor, [mission]).length === 1 ||
(typeof mission.projectId === 'string' && projectIds.has(mission.projectId)),
);
}
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
if (!mission) return false;
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
}
private async assertTaskReferencesAuthorized(
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) {
throw new Error('MCP task project scope denied');
}
if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) {
throw new Error('MCP task mission scope denied');
}
}
private async assertTaskCreateScopeAuthorized(
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
}
private async filterTasksForActor<T extends TaskLike>(
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return tasks;
const [projects, missions] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
]);
const projectIds = new Set(
filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id),
);
const missionIds = new Set(
(await this.filterMissionsForActor(actor, missions as MissionLike[])).map(
(mission) => mission.id,
),
);
return tasks.filter(
(task) =>
task.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
);
}
/**
* Registers all platform tools on the given McpServer instance.
*/
private registerTools(server: McpServer, _userId: string): void {
registerTools(server: McpServer, actor: McpActorContext): void {
// ─── Brain: Project tools ────────────────────────────────────────────
server.registerTool(
'brain_list_projects',
{
description: 'List all projects in the brain.',
inputSchema: z.object({}),
inputSchema: strictObject({}),
},
async () => {
const projects = await this.brain.projects.findAll();
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_projects', params);
const projects = filterProjectsForActor(
actor,
(await this.brain.projects.findAll()) as ProjectLike[],
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
};
@@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy {
'brain_get_project',
{
description: 'Get a project by ID.',
inputSchema: z.object({
inputSchema: strictObject({
id: z.string().describe('Project ID (UUID)'),
}),
},
async ({ id }) => {
const project = await this.brain.projects.findById(id);
async ({ id, ...params }) => {
assertMcpToolAuthorized(actor, 'brain_get_project', params);
const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined;
const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined;
return {
content: [
{
type: 'text' as const,
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
text: authorizedProject
? JSON.stringify(authorizedProject, null, 2)
: `Project not found: ${id}`,
},
],
};
@@ -119,20 +428,23 @@ export class McpService implements OnModuleDestroy {
'brain_list_tasks',
{
description: 'List tasks, optionally filtered by project, mission, or status.',
inputSchema: z.object({
inputSchema: strictObject({
projectId: z.string().optional().describe('Filter by project ID'),
missionId: z.string().optional().describe('Filter by mission ID'),
status: z.string().optional().describe('Filter by status'),
}),
},
async ({ projectId, missionId, status }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_tasks', params);
const { projectId, missionId, status } = params;
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
let tasks;
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
else tasks = await this.brain.tasks.findAll();
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]);
return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] };
},
);
@@ -140,7 +452,7 @@ export class McpService implements OnModuleDestroy {
'brain_create_task',
{
description: 'Create a new task in the brain.',
inputSchema: z.object({
inputSchema: strictObject({
title: z.string().describe('Task title'),
description: z.string().optional().describe('Task description'),
projectId: z.string().optional().describe('Project ID'),
@@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy {
}),
},
async (params) => {
assertMcpToolAuthorized(actor, 'brain_create_task', params);
await this.assertTaskCreateScopeAuthorized(actor, params);
type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await this.brain.tasks.create({
...params,
@@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy {
'brain_update_task',
{
description: 'Update an existing task.',
inputSchema: z.object({
inputSchema: strictObject({
id: z.string().describe('Task ID'),
title: z.string().optional(),
description: z.string().optional(),
@@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy {
.optional()
.describe('not-started, in-progress, blocked, done, cancelled'),
priority: z.string().optional(),
projectId: z.string().optional().describe('Project ID'),
missionId: z.string().optional().describe('Mission ID'),
}),
},
async ({ id, ...updates }) => {
assertMcpToolAuthorized(actor, 'brain_update_task', updates);
const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined;
if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) {
return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] };
}
await this.assertTaskReferencesAuthorized(actor, updates);
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await this.brain.tasks.update(id, {
@@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy {
'brain_list_missions',
{
description: 'List all missions, optionally filtered by project.',
inputSchema: z.object({
inputSchema: strictObject({
projectId: z.string().optional().describe('Filter by project ID'),
}),
},
async ({ projectId }) => {
const missions = projectId
? await this.brain.missions.findByProject(projectId)
: await this.brain.missions.findAll();
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_missions', params);
const { projectId } = params;
const missions = await this.filterMissionsForActor(
actor,
(projectId
? await this.brain.missions.findByProject(projectId)
: await this.brain.missions.findAll()) as MissionLike[],
);
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
},
);
@@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy {
server.registerTool(
'brain_list_conversations',
{
description: 'List conversations for a user.',
inputSchema: z.object({
userId: z.string().describe('User ID'),
}),
description: 'List conversations for the authenticated MCP actor.',
inputSchema: strictObject({}),
},
async ({ userId }) => {
const conversations = await this.brain.conversations.findAll(userId);
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
const conversations = await this.brain.conversations.findAll(actor.userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
};
@@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy {
'memory_search',
{
description:
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
inputSchema: z.object({
userId: z.string().describe('User ID to search memory for'),
'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
inputSchema: strictObject({
query: z.string().describe('Natural language search query'),
limit: z.number().optional().describe('Max results (default 5)'),
}),
},
async ({ userId, query, limit }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_search', params);
const { query, limit } = params;
if (!this.embeddings.available) {
return {
content: [
@@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy {
};
}
const embedding = await this.embeddings.embed(query);
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
const results = await this.memory.insights.searchByEmbedding(
actor.userId,
embedding,
limit ?? 5,
);
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
},
);
@@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy {
server.registerTool(
'memory_get_preferences',
{
description: 'Retrieve stored preferences for a user.',
inputSchema: z.object({
userId: z.string().describe('User ID'),
description: 'Retrieve stored preferences for the authenticated MCP actor.',
inputSchema: strictObject({
category: z
.string()
.optional()
.describe('Filter by category: communication, coding, workflow, appearance, general'),
}),
},
async ({ userId, category }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_get_preferences', params);
const { category } = params;
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
const prefs = category
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
: await this.memory.preferences.findByUser(userId);
? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
: await this.memory.preferences.findByUser(actor.userId);
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
},
);
@@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_preference',
{
description:
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
inputSchema: z.object({
userId: z.string().describe('User ID'),
'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
inputSchema: strictObject({
key: z.string().describe('Preference key'),
value: z.string().describe('Preference value (JSON string)'),
category: z
@@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy {
.describe('Category: communication, coding, workflow, appearance, general'),
}),
},
async ({ userId, key, value, category }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_save_preference', params);
const { key, value, category } = params;
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
let parsedValue: unknown;
try {
@@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy {
parsedValue = value;
}
const pref = await this.memory.preferences.upsert({
userId,
userId: actor.userId,
key,
value: parsedValue,
category: (category as Cat) ?? 'general',
@@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_insight',
{
description:
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
inputSchema: z.object({
userId: z.string().describe('User ID'),
'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
inputSchema: strictObject({
content: z.string().describe('The insight or knowledge to store'),
category: z
.string()
@@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy {
.describe('Category: decision, learning, preference, fact, pattern, general'),
}),
},
async ({ userId, content, category }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_save_insight', params);
const { content, category } = params;
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
const insight = await this.memory.insights.create({
userId,
userId: actor.userId,
content,
embedding,
source: 'agent',
@@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy {
{
description:
'Get the current orchestration mission status including milestones, tasks, and active session.',
inputSchema: z.object({
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
inputSchema: strictObject({}),
},
async ({ projectPath }) => {
const resolvedPath = projectPath ?? process.cwd();
const status = await this.coordService.getMissionStatus(resolvedPath);
async (params) => {
assertMcpToolAuthorized(actor, 'coord_mission_status', params);
const status = await this.coordService.getMissionStatus(process.cwd());
return {
content: [
{
@@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy {
'coord_list_tasks',
{
description: 'List all tasks from the orchestration TASKS.md file.',
inputSchema: z.object({
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
inputSchema: strictObject({}),
},
async ({ projectPath }) => {
const resolvedPath = projectPath ?? process.cwd();
const tasks = await this.coordService.listTasks(resolvedPath);
async (params) => {
assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
const tasks = await this.coordService.listTasks(process.cwd());
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
},
);
@@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy {
'coord_task_detail',
{
description: 'Get detailed status for a specific orchestration task.',
inputSchema: z.object({
inputSchema: strictObject({
taskId: z.string().describe('Task ID (e.g. P2-005)'),
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
},
async ({ taskId, projectPath }) => {
const resolvedPath = projectPath ?? process.cwd();
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
async (params) => {
assertMcpToolAuthorized(actor, 'coord_task_detail', params);
const { taskId } = params;
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
return {
content: [
{
+21 -1
View File
@@ -3,8 +3,10 @@ import {
createMemory,
type Memory,
createMemoryAdapter,
createOperatorMemoryPlugin,
type MemoryAdapter,
type MemoryConfig,
type OperatorMemoryPlugin,
} from '@mosaicstack/memory';
import type { Db } from '@mosaicstack/db';
import type { StorageAdapter } from '@mosaicstack/storage';
@@ -14,6 +16,9 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
import { MEMORY } from './memory.tokens.js';
import { MemoryController } from './memory.controller.js';
import { EmbeddingService } from './embedding.service.js';
import { redactSensitiveContent } from '@mosaicstack/log';
export const OPERATOR_MEMORY_PLUGIN = 'OPERATOR_MEMORY_PLUGIN';
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
@@ -38,9 +43,24 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter)
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
},
{
provide: OPERATOR_MEMORY_PLUGIN,
useFactory: (adapter: MemoryAdapter): OperatorMemoryPlugin | null => {
const instanceId = process.env['MOSAIC_OPERATOR_MEMORY_INSTANCE_ID']?.trim();
const namespace = process.env['MOSAIC_OPERATOR_MEMORY_NAMESPACE']?.trim();
if (!instanceId || !namespace) return null;
return createOperatorMemoryPlugin({
adapter,
instanceId,
namespace,
redact: (content) => redactSensitiveContent(content).content,
});
},
inject: [MEMORY_ADAPTER],
},
EmbeddingService,
],
controllers: [MemoryController],
exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService],
exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
})
export class MemoryModule {}
@@ -0,0 +1,443 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createDiscordIngressEnvelope,
verifyDiscordIngressEnvelope,
DiscordPlugin,
type DiscordIngressPayload,
parseDiscordInteractionBindings,
resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding,
} from '@mosaicstack/discord-plugin';
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
import { ChatGateway } from '../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
import { DiscordReplayProtector } from './discord-replay-protector.js';
const SERVICE_TOKEN = 'test-service-token';
const ENV_KEYS = [
'DISCORD_SERVICE_TOKEN',
'DISCORD_SERVICE_USER_ID',
'DISCORD_SERVICE_TENANT_ID',
'DISCORD_INTERACTION_BINDINGS',
'DISCORD_ALLOWED_GUILD_IDS',
'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
] as const;
const savedEnv = new Map<string, string | undefined>();
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': {
role: role === 'admin' ? 'admin' : 'operator',
mosaicUserId: 'mosaic-admin-001',
},
},
},
]);
}
afterEach((): void => {
for (const key of ENV_KEYS) {
const value = savedEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
savedEnv.clear();
});
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
const entries = new Map<string, string>();
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => entries.set(key, value),
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
function discordGateway(role: 'admin' | 'member'): {
gateway: ChatGateway;
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
consumedActions: Array<{ actorId: string; correlationId: string }>;
durable: { getSnapshot: ReturnType<typeof vi.fn> };
audit: { record: ReturnType<typeof vi.fn> };
} {
const authorization = commandAuthorization(role);
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const audit = { record: vi.fn().mockResolvedValue(undefined) };
const runtimeRegistry = new RuntimeProviderService(
{
require: () => ({
capabilities: async () => ({ supported: ['session.terminate'] }),
terminate: async () => undefined,
}),
} as never,
{ record: async () => undefined } as never,
{
consume: async (approvalId, action) => {
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
},
},
);
return {
gateway: new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
authorization,
runtimeRegistry,
durable as never,
audit as never,
),
client: { data: { discordService: true }, emit: vi.fn() },
consumedActions,
durable,
audit,
};
}
function ingressEnvelope(
content: string,
messageId: string,
overrides: Partial<DiscordIngressPayload> = {},
): ReturnType<typeof createDiscordIngressEnvelope> {
return createDiscordIngressEnvelope(
createPayload({ content, messageId, ...overrides }),
SERVICE_TOKEN,
);
}
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
return {
correlationId: 'correlation-001',
messageId: 'discord-message-001',
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'discord-channel-001',
content: 'hello Tess',
...overrides,
};
}
describe('Discord ingress security', () => {
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
const [binding] = parseDiscordInteractionBindings(
JSON.stringify([
{
instanceId: 'Nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': 'admin' },
},
]),
);
expect(
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
).toEqual(binding);
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
});
it('binds a differently named configured interaction instance without code changes', () => {
const binding = resolveDiscordInteractionBinding(
[
{
instanceId: 'Nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
},
],
'guild-001',
'channel-001',
'user-001',
'send',
);
expect(binding?.instanceId).toBe('Nova');
});
it('accepts only the configured Discord service identity', () => {
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
});
it('rejects unauthenticated or tampered service envelopes', () => {
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
expect(
verifyDiscordIngressEnvelope(
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
SERVICE_TOKEN,
),
).toBeNull();
});
it.each([
['guild', { guildId: 'unlisted-guild' }],
['channel', { channelId: 'unlisted-channel' }],
['user', { userId: 'unlisted-user' }],
])(
'rejects an unallowlisted Discord %s',
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toBeNull();
},
);
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
const payload = createPayload({
correlationId: 'correlation-trace-123',
messageId: 'discord-snowflake-987',
});
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toEqual(payload);
});
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
const replayProtector = new DiscordReplayProtector(60_000, 2);
expect(replayProtector.claim('discord-message-001')).toBe(true);
expect(replayProtector.claim('discord-message-001')).toBe(false);
expect(replayProtector.claim('discord-message-002')).toBe(true);
expect(replayProtector.claim('discord-message-003')).toBe(true);
expect(replayProtector.size).toBe(2);
});
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
configureDiscordEnv();
const { gateway, client, consumedActions } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'approve-message', {
correlationId: 'approval-ingress-correlation',
}),
);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
success: boolean;
};
expect(approval.success).toBe(true);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', {
correlationId: 'stop-ingress-correlation',
}),
);
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
correlationId: 'stop-ingress-correlation',
success: true,
});
expect(consumedActions).toEqual([
{
actorId: 'mosaic-admin-001',
correlationId: expect.stringMatching(/^discord-action:v1:/),
},
]);
});
it('audits a Discord mint-side authorization denial', async () => {
configureDiscordEnv();
const { gateway, client, audit } = discordGateway('member');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'denied-approve'),
);
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001',
success: false,
approvalId: undefined,
expiresAt: undefined,
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
outcome: 'denied',
operation: 'session.terminate',
errorCode: 'policy_denied',
}),
);
});
it.each([
[
'binding',
() => {
process.env['MOSAIC_AGENT_NAME'] = 'Other';
},
],
[
'durable session',
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
durable.getSnapshot.mockResolvedValueOnce({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
},
],
])(
'rejects approval when the %s targets a different runtime agent',
async (_source, configure) => {
configureDiscordEnv();
const { gateway, client, durable } = discordGateway('admin');
configure(durable);
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'mismatched-agent-approve'),
);
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001',
success: false,
approvalId: undefined,
expiresAt: undefined,
});
},
);
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('member');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'member-approve'),
);
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001',
success: false,
approvalId: undefined,
expiresAt: undefined,
});
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope('/stop forged', 'unpaired-stop'),
);
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
});
it('rejects replaying a Discord-created termination approval', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'replay-approve', {
correlationId: 'replay-approval-correlation',
}),
);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
};
await gateway.handleDiscordStop(
client as never,
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', {
correlationId: 'replay-stop-correlation-one',
}),
);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', {
correlationId: 'replay-stop-correlation-two',
}),
);
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
true,
false,
]);
});
it('accepts a thread message through its allowed bound parent channel', () => {
const emitted = vi.fn();
const plugin = new DiscordPlugin({
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [
{
instanceId: 'Nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
},
],
});
const internals = plugin as unknown as {
client: { user: { id: string } };
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
handleDiscordMessage(message: unknown): void;
};
internals.client = { user: { id: 'bot-001' } };
internals.socket = { connected: true, emit: emitted };
internals.handleDiscordMessage({
id: 'thread-message',
guildId: 'guild-001',
channelId: 'thread-001',
author: { id: 'user-001', bot: false },
mentions: { has: () => true },
content: '<@bot-001> hello from thread',
channel: { parentId: 'channel-001' },
attachments: new Map(),
});
const [, envelope] = emitted.mock.calls[0] as [
string,
ReturnType<typeof createDiscordIngressEnvelope>,
];
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
});
});
@@ -0,0 +1,40 @@
/**
* Bounded replay cache for Discord's globally unique native message IDs.
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
*/
export class DiscordReplayProtector {
private readonly claimedAt = new Map<string, number>();
constructor(
private readonly ttlMs = 15 * 60 * 1000,
private readonly maxEntries = 10_000,
) {}
get size(): number {
return this.claimedAt.size;
}
/** Claims an ID exactly once within its bounded retention window. */
claim(messageId: string, now = Date.now()): boolean {
this.prune(now);
if (this.claimedAt.has(messageId)) return false;
this.claimedAt.set(messageId, now);
this.evictOverflow();
return true;
}
private prune(now: number): void {
for (const [messageId, claimedAt] of this.claimedAt) {
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
}
}
private evictOverflow(): void {
while (this.claimedAt.size > this.maxEntries) {
const oldestMessageId = this.claimedAt.keys().next().value;
if (oldestMessageId === undefined) return;
this.claimedAt.delete(oldestMessageId);
}
}
}
+26 -1
View File
@@ -6,7 +6,7 @@ import {
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
import { PluginService } from './plugin.service.js';
import type { IChannelPlugin } from './plugin.interface.js';
@@ -50,19 +50,44 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
function requiredDiscordAllowlist(name: string): string[] {
const value = process.env[name]
?.split(',')
.map((id: string): string => id.trim())
.filter((id: string): boolean => id.length > 0);
if (!value || value.length === 0) {
throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`);
}
return value;
}
function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN'];
const discordGuildId = process.env['DISCORD_GUILD_ID'];
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN'];
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordToken) {
if (!discordServiceToken || !discordServiceUserId) {
throw new Error(
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
);
}
plugins.push(
new DiscordChannelPluginAdapter(
new DiscordPlugin({
token: discordToken,
guildId: discordGuildId,
gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken,
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
interactionBindings: parseDiscordInteractionBindings(
process.env['DISCORD_INTERACTION_BINDINGS'],
),
}),
),
);
@@ -1,9 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { ActorTenantScope } from '../auth/session-scope.js';
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
`mosaic:session:${sessionId}:system:fragments`;
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
`${scope.tenantId}:${scope.userId}:${sessionId}`;
const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) =>
`mosaic:session:${scopedSessionId(sessionId, scope)}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) =>
`mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`;
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
interface OverrideFragment {
@@ -20,9 +24,9 @@ export class SystemOverrideService {
this.handle = createQueue();
}
async set(sessionId: string, override: string): Promise<void> {
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
// Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing
? (JSON.parse(existing) as OverrideFragment[])
: [];
@@ -37,11 +41,11 @@ export class SystemOverrideService {
// Store both: fragments array and condensed result
const pipeline = this.handle.redis.pipeline();
pipeline.setex(
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
SYSTEM_OVERRIDE_TTL_SECONDS,
JSON.stringify(fragments),
);
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
await pipeline.exec();
this.logger.debug(
@@ -49,21 +53,21 @@ export class SystemOverrideService {
);
}
async get(sessionId: string): Promise<string | null> {
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
}
async renew(sessionId: string): Promise<void> {
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
await pipeline.exec();
}
async clear(sessionId: string): Promise<void> {
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
);
this.logger.debug(`Cleared system override for session ${sessionId}`);
}

Some files were not shown because too many files have changed in this diff Show More