Compare commits

...

107 Commits

Author SHA1 Message Date
e41fedb3c2 feat(api): add terminal session persistence with Prisma model and CRUD service
Some checks failed
ci/woodpecker/push/api Pipeline failed
Adds database-backed TerminalSession model with ACTIVE/CLOSED status enum,
migration SQL, TerminalSessionService (create/findByWorkspace/close/findById),
DTO file with class-validator decorators, unit tests (12 tests), and module
registration. Workspace relation and indexed columns enable efficient session
listing and recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:46:58 -06:00
5ba77d8952 fix(web): add random suffix to fallback assistant message IDs to prevent collisions
Some checks failed
ci/woodpecker/push/web Pipeline failed
Message IDs generated in the fallback path used only Date.now() which caused
ID collisions in rapid-send scenarios (multiple sends within the same millisecond).
Adding the same Math.random() suffix used by userMessage IDs ensures uniqueness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:41:43 -06:00
7de0e734b0 feat(web): implement SSE chat streaming with real-time token rendering (#516)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 02:39:43 +00:00
6290fc3d53 feat(api): add terminal WebSocket gateway with PTY session management (#515)
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/api Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 02:27:29 +00:00
9f4de1682f fix(api): resolve CSRF guard ordering with global AuthGuard (#514)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 02:26:02 +00:00
374ca7ace3 docs: initialize MS19 Chat & Terminal mission planning (#513)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 01:49:40 +00:00
72c64d2eeb fix(api): add global /api prefix to resolve frontend route mismatch (#507)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 01:13:48 +00:00
5f6c520a98 fix(auth): prevent login page freeze on OAuth sign-in failure (#506)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-25 01:59:36 +00:00
9a7673bea2 docs: close out MS18 Theme & Widget System mission (#505)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 03:01:54 +00:00
91934b9933 docs: update mission artifacts for MS18 completion (#504)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 02:29:06 +00:00
7f89682946 test(web): add unit tests for MS18 components (#503)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 02:23:05 +00:00
8b4c565f20 feat(web): add kanban board filtering with URL param persistence (#502)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 02:09:37 +00:00
d5ecc0b107 feat(web): add markdown round-trip and replace textarea with Tiptap (#501)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 01:40:34 +00:00
a81c4a5edd feat(web): add Tiptap WYSIWYG KnowledgeEditor component (#500)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 01:23:57 +00:00
ff5a09c3fb feat(web): add widget config dialog and layout management controls (#499)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 01:11:47 +00:00
f93fa60fff feat(web): add widget picker drawer for dashboard customization (#498)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 00:59:45 +00:00
cc56f2cbe1 feat(web): migrate dashboard to WidgetGrid with layout persistence (#497)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 00:50:24 +00:00
f9cccd6965 feat(api): seed 7 widget definitions for dashboard system (#496)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 00:28:02 +00:00
90c3bbccdf feat(web): add theme selection UI in Settings > Appearance (#495)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 14:18:16 +00:00
79286e98c6 feat(web): upgrade ThemeProvider for multi-theme registry (#494)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 14:09:10 +00:00
cfd1def4a9 feat(web): add theme definition system with 5 built-in themes (#493)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 13:59:01 +00:00
f435d8e8c6 docs: initialize MS18 Theme & Widget System mission (#492)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 13:36:10 +00:00
3d78b09064 docs: close out MS16+MS17 mission (#486)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 13:27:22 +00:00
a7955b9b32 docs: mark MS16+MS17 milestone complete (#485)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 13:16:38 +00:00
372cc100cc docs: update PRD statuses and mission artifacts for MS16+MS17 (#484)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 05:09:04 +00:00
37cf813b88 fix(web): update calendar and knowledge tests for real API integration (#483)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 05:04:55 +00:00
3d5b50af11 feat(web): add profile page with user info and preferences (#482)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:50:44 +00:00
f30c2f790c feat(web): add file manager page with list/grid views (#481)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:39:19 +00:00
05b1a93ccb feat(web): add logs and telemetry page with filtering and auto-refresh (#480)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:38:15 +00:00
a78a8b88e1 feat(web): add project workspace page with tasks and agent sessions (#479)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:29:39 +00:00
172ed1d40f feat(web): add kanban board page with drag-and-drop (#478)
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:26:25 +00:00
ee2ddfc8b8 feat(web): add projects page with CRUD operations (#477)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:13:26 +00:00
5a6d00a064 feat(web): wire knowledge pages to real API data (#476)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 04:12:14 +00:00
ffda74ec12 test(web): update tasks page tests for real API integration (#475)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:59:56 +00:00
f97be2e6a3 feat(web): wire calendar page to real API data (#474)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:51:15 +00:00
97606713b5 feat(web): wire tasks page to real API data (#473)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:51:08 +00:00
d0c720e6da feat(web): add custom 404 pages for global and authenticated routes (#472)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:43:55 +00:00
64e817cfb8 feat(web): add settings root index page with category cards (#471)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:42:01 +00:00
cd5c2218c8 chore(orchestrator): bootstrap MS16+MS17 planning (#470)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 03:29:53 +00:00
f643d2bc04 docs: mark mission complete (MS-P4-003) (#465)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 02:11:13 +00:00
8957904ea9 Phase 4: Deploy + Smoke Test (#463) (#464)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 02:09:43 +00:00
458cac7cdd Phase 3: Agent Cycle Visibility (#461) (#462)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 01:07:29 +00:00
7581d26567 Phase 2: Task Ingestion Pipeline (#459) (#460)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 00:54:55 +00:00
07f5225a76 Phase 1: Dashboard Polish + Theming (#457) (#458)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 00:16:45 +00:00
7c55464d54 fix: add mission detection to session hooks (#456)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 23:42:21 +00:00
ea1620fa7a docs: initialize go-live MVP mission with coordinator protocol (#455)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 23:37:13 +00:00
d218902cb0 docs: design system reference and task completion (MS15-DOC-001) (#454)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 21:20:28 +00:00
b43e860c40 feat(web): Phase 3 — Dashboard Page (#450) (#453)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 21:18:50 +00:00
716f230f72 feat(ui,web): Phase 2 — Shared Components & Terminal Panel (#449) (#452)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 21:12:13 +00:00
a5ed260fbd feat(web): MS15 Phase 1 — Design System & App Shell (#451)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 20:57:06 +00:00
9b5c15ca56 style(ui): use padding for AuthDivider vertical spacing (#446) (#447)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 18:02:45 +00:00
74c8c376b7 docs(coolify): update deployment docs with operations guide (#445)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 08:05:47 +00:00
9901fba61e docs: add Coolify deployment guide and compose file (#444)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 07:40:24 +00:00
17144b1c42 style(ui): refine login card shape and divider spacing (#439)
Some checks are pending
ci/woodpecker/push/orchestrator Pipeline is running
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 06:19:23 +00:00
a6f75cd587 fix(ui): use arbitrary opacity for AuthCard dark background (#438)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 05:33:14 +00:00
06e54328d5 fix(web): force dynamic rendering for runtime env injection (#437)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-22 03:54:12 +00:00
7480deff10 fix(web): add Tailwind CSS setup for design system rendering (#436)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 23:36:16 +00:00
1b66417be5 fix(web): restore login page design and add runtime config injection (#435)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 23:16:02 +00:00
23d610ba5b chore: switch from develop/dev to main/latest image tags (#434)
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 22:05:07 +00:00
25ae14aba1 fix(web): resolve flaky CI test failures (#433)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 21:12:00 +00:00
1425893318 Merge pull request 'Merge develop into main — branch consolidation' (#432) from merge/develop-to-main into main
Some checks failed
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-21 20:56:40 +00:00
bc4c1f9c70 Merge develop into main
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Consolidate all feature and fix branches into main:
- feat: orchestrator observability + mosaic rails integration (#422)
- fix: post-422 CI and compose env follow-up (#423)
- fix: orchestrator startup provider-key requirements (#425)
- fix: BetterAuth OAuth2 flow and compose wiring (#426)
- fix: BetterAuth UUID ID generation (#427)
- test: web vitest localStorage/file warnings (#428)
- fix: auth frontend remediation + review hardening (#421)
- Plus numerous Docker, deploy, and auth fixes from develop

Lockfile conflict resolved by regenerating from merged package.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:52:43 -06:00
d66451cf48 fix(ci): suppress Next.js bundled tar/minimatch CVEs in trivy (#431)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 20:40:17 +00:00
c23ebca648 fix(ci): resolve pipeline #516 audit and test failures (#429)
Some checks failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-21 20:11:58 +00:00
Jason Woltje
eae55bc4a3 chore: mosaic upgrade — centralize AGENTS.md, update CLAUDE.md pointer
CLAUDE.md replaced with thin pointer to ~/.config/mosaic/AGENTS.md.
SOUL.md and AGENTS.md now managed globally by the Mosaic framework.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:08:25 -06:00
b5ac2630c1 docs(auth): record digest-based deploy fix verification 2026-02-18 23:39:06 -06:00
8424a28faa fix(auth): use set_config for transaction-scoped RLS context
All checks were successful
ci/woodpecker/push/api Pipeline was successful
2026-02-18 23:23:15 -06:00
d2cec04cba fix(auth): preserve raw BetterAuth cookie token for session lookup
All checks were successful
ci/woodpecker/push/api Pipeline was successful
2026-02-18 23:06:37 -06:00
9ac971e857 chore(deploy): align swarm auth env with deployed stack
All checks were successful
ci/woodpecker/push/api Pipeline was successful
2026-02-18 22:40:22 -06:00
0c2a6b14cf fix(auth): verify BetterAuth sessions via cookie headers 2026-02-18 22:39:54 -06:00
af299abdaf debug(auth): log session cookie source
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
2026-02-18 21:36:01 -06:00
fa9f173f8e chore(web): use prod-only deps in runtime image
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-18 21:13:12 -06:00
7935d86015 chore(web): avoid pnpm in runtime image to reduce CVE noise
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-18 20:24:22 -06:00
f43631671f chore(deps): override tar to 7.5.8 for trivy
Some checks failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/api Pipeline was successful
2026-02-18 20:01:10 -06:00
8328f9509b Merge pull request 'test(web): silence localStorage-file warnings in vitest' (#428) from fix/web-test-warnings-2 into develop
Some checks failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/api Pipeline was successful
Reviewed-on: #428
2026-02-19 01:45:06 +00:00
f72e8c2da9 chore(deps): override minimatch to 10.2.1 for audit fix
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-18 19:41:38 -06:00
1a668627a3 test(web): silence localStorage-file warnings in vitest setup
Some checks failed
ci/woodpecker/push/web Pipeline failed
2026-02-18 19:38:23 -06:00
bd3625ae1b Merge pull request 'fix(auth): generate UUID ids for BetterAuth Prisma writes' (#427) from fix/authentik-betterauth-interop into develop
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Reviewed-on: #427
2026-02-19 01:07:32 +00:00
aeac188d40 chore(deps): override minimatch to 10.2.1 for audit fix
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
2026-02-18 18:53:25 -06:00
f219dd71a0 fix(auth): use UUID id generation for BetterAuth DB models
Some checks failed
ci/woodpecker/push/api Pipeline failed
2026-02-18 18:49:16 -06:00
2c3c1f67ac Merge pull request 'fix(auth): restore BetterAuth OAuth2 flow and compose wiring' (#426) from fix/authentik-betterauth-interop into develop
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Reviewed-on: #426
2026-02-18 05:44:19 +00:00
dedc1af080 fix(auth): restore BetterAuth OIDC flow across api/web/compose
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-17 23:37:49 -06:00
3b16b2c743 Merge pull request 'Fix orchestrator startup provider-key requirements for Issue 424' (#425) from fix/post-422-runtime into develop
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
Reviewed-on: #425
2026-02-17 23:17:39 +00:00
Jason Woltje
6fd8e85266 fix(orchestrator): make provider-aware Claude key startup requirements
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
2026-02-17 17:15:42 -06:00
Jason Woltje
d3474cdd74 chore(orchestrator): bootstrap issue 424 2026-02-17 17:05:09 -06:00
157b702331 Merge pull request 'fix(runtime): post-422 CI and compose env follow-up' (#423) from fix/post-422-runtime into develop
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Reviewed-on: #423
2026-02-17 22:47:50 +00:00
Jason Woltje
63c6a129bd fix(runtime): stabilize LinkAutocomplete nav test and wire required compose env
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-17 16:42:34 -06:00
4a4aee7b7c Merge pull request 'feat: finalize orchestrator observability and mosaic rails integration' (#422) from feature/mosaic-stack-finalization into develop
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/orchestrator Pipeline was successful
Reviewed-on: #422
2026-02-17 22:24:01 +00:00
Jason Woltje
9d9a01f5f7 feat(web): add orchestrator readiness badge and resilient events parsing
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-17 16:20:03 -06:00
Jason Woltje
5bce7dbb05 feat(web): show latest orchestrator event in task progress widget
Some checks failed
ci/woodpecker/push/web Pipeline failed
2026-02-17 16:12:40 -06:00
Jason Woltje
ab902250f8 feat(web-hud): seed default layout with orchestration widgets
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-17 16:07:09 -06:00
Jason Woltje
d34f097a5c feat(web): add orchestrator events widget with matrix signal visibility
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-17 15:56:12 -06:00
Jason Woltje
f4ad7eba37 fix(web-hud): support hyphenated widget IDs with regression tests
Some checks failed
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline failed
2026-02-17 15:49:09 -06:00
Jason Woltje
4d089cd020 feat(orchestrator): add recent events API and monitor script 2026-02-17 15:44:43 -06:00
Jason Woltje
3258cd4f4d feat(orchestrator): add SSE events, queue controls, and mosaic rails sync 2026-02-17 15:39:15 -06:00
35dd623ab5 Merge pull request 'fix(#411): complete auth/frontend remediation and review hardening' (#421) from fix/auth-frontend-remediation into develop
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Reviewed-on: #421
2026-02-17 21:24:13 +00:00
Jason Woltje
758b2a839b fix(web-tests): stabilize async auth and usage page assertions
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-17 15:15:54 -06:00
af113707d9 Merge branch 'develop' into fix/auth-frontend-remediation
Some checks failed
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/api Pipeline was successful
2026-02-17 20:35:59 +00:00
Jason Woltje
57d0f5d2a3 fix(#411): resolve CI lint crash from ajv override
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
Drop the global ajv override that forced ESLint onto an incompatible major, then move @mosaic/config lint tooling deps to devDependencies so production audit stays clean without impacting runtime deps.
2026-02-17 14:28:55 -06:00
Jason Woltje
ad428598a9 docs(#411): normalize AGENTS standards paths
Some checks failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/api Pipeline failed
ci/woodpecker/push/web Pipeline failed
2026-02-17 14:21:19 -06:00
Jason Woltje
cab8d690ab fix(#411): complete 2026-02-17 remediation sweep
Apply RLS context at task service boundaries, harden orchestrator/web integration and session startup behavior, re-enable targeted frontend tests, and lock vulnerable transitive dependencies so QA and security gates pass cleanly.
2026-02-17 14:19:15 -06:00
0a780a5062 Merge pull request 'bootstrap mosaic-stack to Mosaic standards layer' (#420) from fix/auth-frontend-remediation into main
Some checks failed
ci/woodpecker/manual/api Pipeline failed
ci/woodpecker/manual/web Pipeline failed
ci/woodpecker/manual/orchestrator Pipeline failed
ci/woodpecker/manual/infra Pipeline was successful
ci/woodpecker/manual/coordinator Pipeline was successful
Reviewed-on: #420
2026-02-17 18:51:54 +00:00
a1515676db Merge branch 'main' into fix/auth-frontend-remediation
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-17 18:46:50 +00:00
Jason Woltje
254f85369b add repo lifecycle hooks for mosaic-stack sessions 2026-02-17 12:45:39 -06:00
Jason Woltje
ddf6851bfd bootstrap repo to mosaic standards layer 2026-02-17 12:43:14 -06:00
b719fa0444 Merge pull request 'chore: upgrade Node.js runtime to v24 across codebase' (#419) from fix/auth-frontend-remediation into main
Some checks failed
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline failed
ci/woodpecker/push/web Pipeline was successful
Reviewed-on: #419
2026-02-17 01:04:46 +00:00
Jason Woltje
8961f5b18c chore: upgrade Node.js runtime to v24 across codebase
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
- Update .woodpecker/codex-review.yml: node:22-slim → node:24-slim
- Update packages/cli-tools engines: >=18 → >=24.0.0
- Update README.md, CONTRIBUTING.md, prerequisites docs to reference Node 24+
- Rename eslint.config.js → eslint.config.mjs to eliminate Node 24
  MODULE_TYPELESS_PACKAGE_JSON warnings (ESM detection overhead)
- Add .nvmrc targeting Node 24
- Fix pre-existing no-unsafe-return lint error in matrix-room.service.ts
- Add Campsite Rule to CLAUDE.md
- Regenerate Prisma client for Node 24 compatibility

All Dockerfiles and main CI pipelines already used node:24. This commit
aligns the remaining stragglers (codex-review CI, cli-tools engines,
documentation) and resolves Node 24 ESM module detection warnings.

Quality gates: lint  typecheck  tests  (6 pre-existing API failures)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:33:26 -06:00
306 changed files with 27110 additions and 4096 deletions

View File

@@ -15,6 +15,14 @@ WEB_PORT=3000
# ======================
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3001
# Frontend auth mode:
# - real: Normal auth/session flow
# - mock: Local-only seeded user for FE development (blocked outside NODE_ENV=development)
# Use `mock` locally to continue FE work when auth flow is unstable.
# If omitted, web runtime defaults:
# - development -> mock
# - production -> real
NEXT_PUBLIC_AUTH_MODE=real
# ======================
# PostgreSQL Database
@@ -70,9 +78,9 @@ OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
# Redirect URI must match what's configured in Authentik
# Development: http://localhost:3001/auth/callback/authentik
# Production: https://api.mosaicstack.dev/auth/callback/authentik
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback/authentik
# Development: http://localhost:3001/auth/oauth2/callback/authentik
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
# Authentik PostgreSQL Database
AUTHENTIK_POSTGRES_USER=authentik
@@ -116,6 +124,9 @@ JWT_EXPIRATION=24h
# This is used by BetterAuth for session management and CSRF protection
# Example: openssl rand -base64 32
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
# Optional explicit BetterAuth origin for callback/error URL generation.
# When empty, backend falls back to NEXT_PUBLIC_API_URL.
BETTER_AUTH_URL=
# Trusted Origins (comma-separated list of additional trusted origins for CORS and auth)
# These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
@@ -204,11 +215,9 @@ NODE_ENV=development
# Used by docker-compose.yml (pulls images) and docker-swarm.yml
# For local builds, use docker-compose.build.yml instead
# Options:
# - dev: Pull development images from registry (default, built from develop branch)
# - latest: Pull latest stable images from registry (built from main branch)
# - <commit-sha>: Use specific commit SHA tag (e.g., 658ec077)
# - latest: Pull latest images from registry (default, built from main branch)
# - <version>: Use specific version tag (e.g., v1.0.0)
IMAGE_TAG=dev
IMAGE_TAG=latest
# ======================
# Docker Compose Profiles
@@ -381,6 +390,17 @@ ELEMENT_IMAGE_TAG=latest
# Health endpoints (/health/*) remain unauthenticated
ORCHESTRATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS
# Runtime safety defaults (recommended for low-memory hosts)
MAX_CONCURRENT_AGENTS=2
SESSION_CLEANUP_DELAY_MS=30000
ORCHESTRATOR_QUEUE_NAME=orchestrator-tasks
ORCHESTRATOR_QUEUE_CONCURRENCY=1
ORCHESTRATOR_QUEUE_MAX_RETRIES=3
ORCHESTRATOR_QUEUE_BASE_DELAY_MS=1000
ORCHESTRATOR_QUEUE_MAX_DELAY_MS=60000
SANDBOX_DEFAULT_MEMORY_MB=256
SANDBOX_DEFAULT_CPU_LIMIT=1.0
# ======================
# AI Provider Configuration
# ======================
@@ -395,8 +415,7 @@ AI_PROVIDER=ollama
OLLAMA_MODEL=llama3.1:latest
# Claude API Key
# Required by the orchestrator service in swarm deployment.
# Also used when AI_PROVIDER=claude for other services.
# Required only when AI_PROVIDER=claude.
# Get your API key from: https://console.anthropic.com/
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY

10
.gitignore vendored
View File

@@ -59,3 +59,13 @@ yarn-error.log*
# Orchestrator reports (generated by QA automation, cleaned up after processing)
docs/reports/qa-automation/
# Repo-local orchestrator runtime artifacts
.mosaic/orchestrator/orchestrator.pid
.mosaic/orchestrator/state.json
.mosaic/orchestrator/tasks.json
.mosaic/orchestrator/matrix_state.json
.mosaic/orchestrator/logs/*.log
.mosaic/orchestrator/results/*
!.mosaic/orchestrator/logs/.gitkeep
!.mosaic/orchestrator/results/.gitkeep

15
.mosaic/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Repo Mosaic Linkage
This repository is attached to the machine-wide Mosaic framework.
## Load Order for Agents
1. `~/.config/mosaic/STANDARDS.md`
2. `AGENTS.md` (this repository)
3. `.mosaic/repo-hooks.sh` (repo-specific automation hooks)
## Purpose
- Keep universal standards in `~/.config/mosaic`
- Keep repo-specific behavior in this repo
- Avoid copying large runtime configs into each project

View File

@@ -0,0 +1,18 @@
{
"enabled": true,
"transport": "matrix",
"matrix": {
"control_room_id": "",
"workspace_id": "",
"homeserver_url": "",
"access_token": "",
"bot_user_id": ""
},
"worker": {
"runtime": "codex",
"command_template": "bash scripts/agent/orchestrator-worker.sh {task_file}",
"timeout_seconds": 7200,
"max_attempts": 1
},
"quality_gates": ["pnpm lint", "pnpm typecheck", "pnpm test"]
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,14 @@
{
"schema_version": 1,
"mission_id": "prd-implementation-20260222",
"name": "PRD implementation",
"description": "",
"project_path": "/home/jwoltje/src/mosaic-stack",
"created_at": "2026-02-23T03:20:55Z",
"status": "active",
"task_prefix": "",
"quality_gates": "",
"milestone_version": "0.0.1",
"milestones": [],
"sessions": []
}

View File

@@ -0,0 +1 @@

10
.mosaic/quality-rails.yml Normal file
View File

@@ -0,0 +1,10 @@
enabled: false
template: ""
# Set enabled: true and choose one template:
# - typescript-node
# - typescript-nextjs
# - monorepo
#
# Apply manually:
# ~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target <repo>

29
.mosaic/repo-hooks.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Repo-specific hooks used by scripts/agent/*.sh for Mosaic Stack.
mosaic_hook_session_start() {
echo "[mosaic-stack] Branch: $(git rev-parse --abbrev-ref HEAD)"
echo "[mosaic-stack] Remotes:"
git remote -v | sed 's/^/[mosaic-stack] /'
if command -v node >/dev/null 2>&1; then
echo "[mosaic-stack] Node: $(node -v)"
fi
if command -v pnpm >/dev/null 2>&1; then
echo "[mosaic-stack] pnpm: $(pnpm -v)"
fi
}
mosaic_hook_critical() {
echo "[mosaic-stack] Recent commits:"
git log --oneline --decorate -n 5 | sed 's/^/[mosaic-stack] /'
echo "[mosaic-stack] Open TODO/FIXME markers (top 20):"
rg -n "(TODO|FIXME|HACK|SECURITY)" apps packages plugins docs --glob '!**/node_modules/**' -S \
| head -n 20 \
| sed 's/^/[mosaic-stack] /' \
|| true
}
mosaic_hook_session_end() {
echo "[mosaic-stack] Working tree summary:"
git status --short | sed 's/^/[mosaic-stack] /' || true
}

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

View File

@@ -6,7 +6,7 @@
# - npm bundled CVEs (5): npm removed from production Node.js images
# - Node.js 20 → 24 LTS migration (#367): base images updated
#
# REMAINING: OpenBao (5 CVEs) + Next.js bundled tar (3 CVEs)
# REMAINING: OpenBao (5 CVEs) + Next.js bundled tar/minimatch (5 CVEs)
# Re-evaluate when upgrading openbao image beyond 2.5.0 or Next.js beyond 16.1.6.
# === OpenBao false positives ===
@@ -17,15 +17,18 @@ CVE-2024-9180 # HIGH: privilege escalation (fixed in 2.0.3)
CVE-2025-59043 # HIGH: DoS via malicious JSON (fixed in 2.4.1)
CVE-2025-64761 # HIGH: identity group root escalation (fixed in 2.4.4)
# === Next.js bundled tar CVEs (upstream — waiting on Next.js release) ===
# Next.js 16.1.6 bundles tar@7.5.2 in next/dist/compiled/tar/ (pre-compiled).
# This is NOT a pnpm dependency — it's embedded in the Next.js package itself.
# === Next.js bundled tar/minimatch CVEs (upstream — waiting on Next.js release) ===
# Next.js 16.1.6 bundles tar@7.5.2 and minimatch@9.0.5 in next/dist/compiled/ (pre-compiled).
# These are NOT pnpm dependencies — they're embedded in the Next.js package itself.
# pnpm overrides cannot reach these; only a Next.js upgrade can fix them.
# Affects web image only (orchestrator and API are clean).
# npm was also removed from all production images, eliminating the npm-bundled copy.
# To resolve: upgrade Next.js when a release bundles tar >= 7.5.7.
# To resolve: upgrade Next.js when a release bundles tar >= 7.5.8 and minimatch >= 10.2.1.
CVE-2026-23745 # HIGH: tar arbitrary file overwrite via unsanitized linkpaths (fixed in 7.5.3)
CVE-2026-23950 # HIGH: tar arbitrary file overwrite via Unicode path collision (fixed in 7.5.4)
CVE-2026-24842 # HIGH: tar arbitrary file creation via hardlink path traversal (needs tar >= 7.5.7)
CVE-2026-26960 # HIGH: tar arbitrary file read/write via malicious archive hardlink (needs tar >= 7.5.8)
CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs minimatch >= 10.2.1)
# === OpenBao Go stdlib (waiting on upstream rebuild) ===
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.

View File

@@ -85,12 +85,11 @@ install -> [ruff-check, mypy, security-bandit, security-pip-audit, test]
## Image Tagging
| Condition | Tag | Purpose |
| ---------------- | -------------------------- | -------------------------- |
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
| `main` branch | `latest` | Current production release |
| `develop` branch | `dev` | Current development build |
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
| Condition | Tag | Purpose |
| ------------- | -------------------------- | -------------------------- |
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
| `main` branch | `latest` | Current latest build |
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
## Required Secrets
@@ -138,5 +137,5 @@ Fails on blockers or critical/high severity security findings.
### Pipeline runs Docker builds on pull requests
- Docker build steps have `when: branch: [main, develop]` guards
- Docker build steps have `when: branch: [main]` guards
- PRs only run quality gates, not Docker builds

View File

@@ -15,6 +15,7 @@ when:
- "turbo.json"
- "package.json"
- ".woodpecker/api.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
@@ -151,12 +152,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:dev"
fi
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
@@ -179,7 +178,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -187,7 +186,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-api
@@ -229,7 +228,7 @@ steps:
}
link_package "stack-api"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-api

View File

@@ -12,7 +12,7 @@ when:
event: pull_request
variables:
- &node_image "node:22-slim"
- &node_image "node:24-slim"
- &install_codex "npm i -g @openai/codex"
steps:

View File

@@ -92,12 +92,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:dev"
fi
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- ruff-check
@@ -124,7 +122,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -132,7 +130,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-coordinator
@@ -174,7 +172,7 @@ steps:
}
link_package "stack-coordinator"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-coordinator

View File

@@ -36,12 +36,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:dev"
fi
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
docker-build-openbao:
@@ -61,12 +59,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:dev"
fi
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
# === Container Security Scans ===
@@ -87,7 +83,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -95,7 +91,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-postgres
@@ -116,7 +112,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -124,7 +120,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-openbao
@@ -167,7 +163,7 @@ steps:
link_package "stack-postgres"
link_package "stack-openbao"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-postgres

View File

@@ -15,6 +15,7 @@ when:
- "turbo.json"
- "package.json"
- ".woodpecker/orchestrator.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
@@ -108,12 +109,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:dev"
fi
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
@@ -136,7 +135,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -144,7 +143,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-orchestrator
@@ -186,7 +185,7 @@ steps:
}
link_package "stack-orchestrator"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-orchestrator

View File

@@ -15,6 +15,7 @@ when:
- "turbo.json"
- "package.json"
- ".woodpecker/web.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
@@ -119,12 +120,10 @@ steps:
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:dev"
fi
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
@@ -147,7 +146,7 @@ steps:
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="dev"
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
@@ -155,7 +154,7 @@ steps:
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-web
@@ -197,7 +196,7 @@ steps:
}
link_package "stack-web"
when:
- branch: [main, develop]
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-web

View File

@@ -1,37 +1,67 @@
# Mosaic Stack — Agent Guidelines
> **Any AI model, coding assistant, or framework working in this codebase MUST read and follow `CLAUDE.md` in the project root.**
## Load Order
`CLAUDE.md` is the authoritative source for:
1. `SOUL.md` (repo identity + behavior invariants)
2. `~/.config/mosaic/STANDARDS.md` (machine-wide standards rails)
3. `AGENTS.md` (repo-specific overlay)
4. `.mosaic/repo-hooks.sh` (repo lifecycle hooks)
- Technology stack and versions
- TypeScript strict mode requirements
- ESLint Quality Rails (error-level enforcement)
- Prettier formatting rules
- Testing requirements (85% coverage, TDD)
- API conventions and database patterns
- Commit format and branch strategy
- PDA-friendly design principles
## Runtime Contract
## Quick Rules (Read CLAUDE.md for Details)
- This file is authoritative for repo-local operations.
- `CLAUDE.md` is a compatibility pointer to `AGENTS.md`.
- Follow universal rails from `~/.config/mosaic/guides/` and `~/.config/mosaic/rails/`.
- **No `any` types** — use `unknown`, generics, or proper types
- **Explicit return types** on all functions
- **Type-only imports** — `import type { Foo }` for types
- **Double quotes**, semicolons, 2-space indent, 100 char width
- **`??` not `||`** for defaults, **`?.`** not `&&` chains
- **All promises** must be awaited or returned
- **85% test coverage** minimum, tests before implementation
## Session Lifecycle
## Updating Conventions
```bash
bash scripts/agent/session-start.sh
bash scripts/agent/critical.sh
bash scripts/agent/session-end.sh
```
If you discover new patterns, gotchas, or conventions while working in this codebase, **update `CLAUDE.md`** — not this file. This file exists solely to redirect agents that look for `AGENTS.md` to the canonical source.
Optional:
## Per-App Context
```bash
bash scripts/agent/log-limitation.sh "Short Name"
bash scripts/agent/orchestrator-daemon.sh status
bash scripts/agent/orchestrator-events.sh recent --limit 50
```
Each app directory has its own `AGENTS.md` for app-specific patterns:
## Repo Context
- Platform: multi-tenant personal assistant stack
- Monorepo: `pnpm` workspaces + Turborepo
- Core apps: `apps/api` (NestJS), `apps/web` (Next.js), orchestrator/coordinator services
- Infrastructure: Docker Compose + PostgreSQL + Valkey + Authentik
## Quick Command Set
```bash
pnpm install
pnpm dev
pnpm test
pnpm lint
pnpm build
```
## Standards and Quality
- Enforce strict typing and no unsafe shortcuts.
- Keep lint/typecheck/tests green before completion.
- Prefer small, focused commits and clear change descriptions.
## App-Specific Overlays
- `apps/api/AGENTS.md`
- `apps/web/AGENTS.md`
- `apps/coordinator/AGENTS.md`
- `apps/orchestrator/AGENTS.md`
## Additional Guidance
- Orchestrator guidance: `docs/claude/orchestrator.md`
- Security remediation context: `docs/reports/codebase-review-2026-02-05/01-security-review.md`
- Code quality context: `docs/reports/codebase-review-2026-02-05/02-code-quality-review.md`
- QA context: `docs/reports/codebase-review-2026-02-05/03-qa-test-coverage.md`

479
CLAUDE.md
View File

@@ -1,477 +1,10 @@
**Multi-tenant personal assistant platform with PostgreSQL backend, Authentik SSO, and MoltBot
integration.**
# CLAUDE Compatibility Pointer
## Conditional Documentation Loading
This file exists so Claude Code sessions load Mosaic standards.
| When working on... | Load this guide |
| ---------------------------------------- | ------------------------------------------------------------------- |
| Orchestrating autonomous task completion | `docs/claude/orchestrator.md` |
| Security remediation (review findings) | `docs/reports/codebase-review-2026-02-05/01-security-review.md` |
| Code quality fixes | `docs/reports/codebase-review-2026-02-05/02-code-quality-review.md` |
| Test coverage gaps | `docs/reports/codebase-review-2026-02-05/03-qa-test-coverage.md` |
## MANDATORY — Read Before Any Response
## Platform Templates
BEFORE responding to any user message, READ `~/.config/mosaic/AGENTS.md`.
Bootstrap templates are at `docs/templates/`. See `docs/templates/README.md` for usage.
## Project Overview
Mosaic Stack is a standalone platform that provides:
- Multi-user workspaces with team sharing
- Task, event, and project management
- Gantt charts and Kanban boards
- MoltBot integration via plugins (stock MoltBot + mosaic-plugin-\*)
- PDA-friendly design throughout
**Repository:** git.mosaicstack.dev/mosaic/stack
**Versioning:** Start at 0.0.1, MVP = 0.1.0
## Technology Stack
| Layer | Technology |
| ---------- | -------------------------------------------- |
| Frontend | Next.js 16 + React + TailwindCSS + Shadcn/ui |
| Backend | NestJS + Prisma ORM |
| Database | PostgreSQL 17 + pgvector |
| Cache | Valkey (Redis-compatible) |
| Auth | Authentik (OIDC) |
| AI | Ollama (configurable: local or remote) |
| Messaging | MoltBot (stock + Mosaic plugins) |
| Real-time | WebSockets (Socket.io) |
| Monorepo | pnpm workspaces + TurboRepo |
| Testing | Vitest + Playwright |
| Deployment | Docker + docker-compose |
## Repository Structure
mosaic-stack/
├── apps/
│ ├── api/ # mosaic-api (NestJS)
│ │ ├── src/
│ │ │ ├── auth/ # Authentik OIDC
│ │ │ ├── tasks/ # Task management
│ │ │ ├── events/ # Calendar/events
│ │ │ ├── projects/ # Project management
│ │ │ ├── brain/ # MoltBot integration
│ │ │ └── activity/ # Activity logging
│ │ ├── prisma/
│ │ │ └── schema.prisma
│ │ └── Dockerfile
│ └── web/ # mosaic-web (Next.js 16)
│ ├── app/
│ ├── components/
│ └── Dockerfile
├── packages/
│ ├── shared/ # Shared types, utilities
│ ├── ui/ # Shared UI components
│ └── config/ # Shared configuration
├── plugins/
│ ├── mosaic-plugin-brain/ # MoltBot skill: API queries
│ ├── mosaic-plugin-calendar/ # MoltBot skill: Calendar
│ ├── mosaic-plugin-tasks/ # MoltBot skill: Tasks
│ └── mosaic-plugin-gantt/ # MoltBot skill: Gantt
├── docker/
│ ├── docker-compose.yml # Turnkey deployment
│ └── init-scripts/ # PostgreSQL init
├── docs/
│ ├── SETUP.md
│ ├── CONFIGURATION.md
│ └── DESIGN-PRINCIPLES.md
├── .env.example
├── turbo.json
├── pnpm-workspace.yaml
└── README.md
## Development Workflow
### Branch Strategy
- `main` — stable releases only
- `develop` — active development (default working branch)
- `feature/*` — feature branches from develop
- `fix/*` — bug fix branches
### Starting Work
````bash
git checkout develop
git pull --rebase
pnpm install
Running Locally
# Start all services (Docker)
docker compose up -d
# Or run individually for development
pnpm dev # All apps
pnpm dev:api # API only
pnpm dev:web # Web only
Testing
pnpm test # Run all tests
pnpm test:api # API tests only
pnpm test:web # Web tests only
pnpm test:e2e # Playwright E2E
Building
pnpm build # Build all
pnpm build:api # Build API
pnpm build:web # Build Web
Design Principles (NON-NEGOTIABLE)
PDA-Friendly Language
NEVER use demanding language. This is critical.
┌─────────────┬──────────────────────┐
│ ❌ NEVER │ ✅ ALWAYS │
├─────────────┼──────────────────────┤
│ OVERDUE │ Target passed │
├─────────────┼──────────────────────┤
│ URGENT │ Approaching target │
├─────────────┼──────────────────────┤
│ MUST DO │ Scheduled for │
├─────────────┼──────────────────────┤
│ CRITICAL │ High priority │
├─────────────┼──────────────────────┤
│ YOU NEED TO │ Consider / Option to │
├─────────────┼──────────────────────┤
│ REQUIRED │ Recommended │
└─────────────┴──────────────────────┘
Visual Indicators
Use status indicators consistently:
- 🟢 On track / Active
- 🔵 Upcoming / Scheduled
- ⏸️ Paused / On hold
- 💤 Dormant / Inactive
- ⚪ Not started
Display Principles
1. 10-second scannability — Key info visible immediately
2. Visual chunking — Clear sections with headers
3. Single-line items — Compact, scannable lists
4. Date grouping — Today, Tomorrow, This Week headers
5. Progressive disclosure — Details on click, not upfront
6. Calm colors — No aggressive reds for status
Reference
See docs/DESIGN-PRINCIPLES.md for complete guidelines.
For original patterns, see: jarvis-brain/docs/DESIGN-PRINCIPLES.md
API Conventions
Endpoints
GET /api/{resource} # List (with pagination, filters)
GET /api/{resource}/:id # Get single
POST /api/{resource} # Create
PATCH /api/{resource}/:id # Update
DELETE /api/{resource}/:id # Delete
Response Format
// Success
{
data: T | T[],
meta?: { total, page, limit }
}
// Error
{
error: {
code: string,
message: string,
details?: any
}
}
Brain Query API
POST /api/brain/query
{
query: "what's on my calendar",
context?: { view: "dashboard", workspace_id: "..." }
}
Database Conventions
Multi-Tenant (RLS)
All workspace-scoped tables use Row-Level Security:
- Always include workspace_id in queries
- RLS policies enforce isolation
- Set session context for current user
Prisma Commands
pnpm prisma:generate # Generate client
pnpm prisma:migrate # Run migrations
pnpm prisma:studio # Open Prisma Studio
pnpm prisma:seed # Seed development data
MoltBot Plugin Development
Plugins live in plugins/mosaic-plugin-*/ and follow MoltBot skill format:
# plugins/mosaic-plugin-brain/SKILL.md
---
name: mosaic-plugin-brain
description: Query Mosaic Stack for tasks, events, projects
version: 0.0.1
triggers:
- "what's on my calendar"
- "show my tasks"
- "morning briefing"
tools:
- mosaic_api
---
# Plugin instructions here...
Key principle: MoltBot remains stock. All customization via plugins only.
Environment Variables
See .env.example for all variables. Key ones:
# Database
DATABASE_URL=postgresql://mosaic:password@localhost:5432/mosaic
# Auth
AUTHENTIK_URL=https://auth.example.com
AUTHENTIK_CLIENT_ID=mosaic-stack
AUTHENTIK_CLIENT_SECRET=...
# Ollama
OLLAMA_MODE=local|remote
OLLAMA_ENDPOINT=http://localhost:11434
# MoltBot
MOSAIC_API_TOKEN=...
Issue Tracking
Issues are tracked at: https://git.mosaicstack.dev/mosaic/stack/issues
Labels
- Priority: p0 (critical), p1 (high), p2 (medium), p3 (low)
- Type: api, web, database, auth, plugin, ai, devops, docs, migration, security, testing,
performance, setup
Milestones
- M1-Foundation (0.0.x)
- M2-MultiTenant (0.0.x)
- M3-Features (0.0.x)
- M4-MoltBot (0.0.x)
- M5-Migration (0.1.0 MVP)
Commit Format
<type>(#issue): Brief description
Detailed explanation if needed.
Fixes #123
Types: feat, fix, docs, test, refactor, chore
Test-Driven Development (TDD) - REQUIRED
**All code must follow TDD principles. This is non-negotiable.**
TDD Workflow (Red-Green-Refactor)
1. **RED** — Write a failing test first
- Write the test for new functionality BEFORE writing any implementation code
- Run the test to verify it fails (proves the test works)
- Commit message: `test(#issue): add test for [feature]`
2. **GREEN** — Write minimal code to make the test pass
- Implement only enough code to pass the test
- Run tests to verify they pass
- Commit message: `feat(#issue): implement [feature]`
3. **REFACTOR** — Clean up the code while keeping tests green
- Improve code quality, remove duplication, enhance readability
- Ensure all tests still pass after refactoring
- Commit message: `refactor(#issue): improve [component]`
Testing Requirements
- **Minimum 85% code coverage** for all new code
- **Write tests BEFORE implementation** — no exceptions
- Test files must be co-located with source files:
- `feature.service.ts` → `feature.service.spec.ts`
- `component.tsx` → `component.test.tsx`
- All tests must pass before creating a PR
- Use descriptive test names: `it("should return user when valid token provided")`
- Group related tests with `describe()` blocks
- Mock external dependencies (database, APIs, file system)
Test Types
- **Unit Tests** — Test individual functions/methods in isolation
- **Integration Tests** — Test module interactions (e.g., service + database)
- **E2E Tests** — Test complete user workflows with Playwright
Running Tests
```bash
pnpm test # Run all tests
pnpm test:watch # Watch mode for active development
pnpm test:coverage # Generate coverage report
pnpm test:api # API tests only
pnpm test:web # Web tests only
pnpm test:e2e # Playwright E2E tests
````
Coverage Verification
After implementing a feature, verify coverage meets requirements:
```bash
pnpm test:coverage
# Check the coverage report in coverage/index.html
# Ensure your files show ≥85% coverage
```
TDD Anti-Patterns to Avoid
❌ Writing implementation code before tests
❌ Writing tests after implementation is complete
❌ Skipping tests for "simple" code
❌ Testing implementation details instead of behavior
❌ Writing tests that don't fail when they should
❌ Committing code with failing tests
Quality Rails - Mechanical Code Quality Enforcement
**Status:** ACTIVE (2026-01-30) - Strict enforcement enabled ✅
Quality Rails provides mechanical enforcement of code quality standards through pre-commit hooks
and CI/CD pipelines. See `docs/quality-rails-status.md` for full details.
What's Enforced (NOW ACTIVE):
- ✅ **Type Safety** - Blocks explicit `any` types (@typescript-eslint/no-explicit-any: error)
- ✅ **Return Types** - Requires explicit return types on exported functions
- ✅ **Security** - Detects SQL injection, XSS, unsafe regex (eslint-plugin-security)
- ✅ **Promise Safety** - Blocks floating promises and misused promises
- ✅ **Code Formatting** - Auto-formats with Prettier on commit
- ✅ **Build Verification** - Type-checks before allowing commit
- ✅ **Secret Scanning** - Blocks hardcoded passwords/API keys (git-secrets)
Current Status:
- ✅ **Pre-commit hooks**: ACTIVE - Blocks commits with violations
- ✅ **Strict enforcement**: ENABLED - Package-level enforcement
- 🟡 **CI/CD pipeline**: Ready (.woodpecker.yml created, not yet configured)
How It Works:
**Package-Level Enforcement** - If you touch ANY file in a package with violations,
you must fix ALL violations in that package before committing. This forces incremental
cleanup while preventing new violations.
Example:
- Edit `apps/api/src/tasks/tasks.service.ts`
- Pre-commit hook runs lint on ENTIRE `@mosaic/api` package
- If `@mosaic/api` has violations → Commit BLOCKED
- Fix all violations in `@mosaic/api` → Commit allowed
Next Steps:
1. Fix violations package-by-package as you work in them
2. Priority: Fix explicit `any` types and type safety issues first
3. Configure Woodpecker CI to run quality gates on all PRs
Why This Matters:
Based on validation of 50 real production issues, Quality Rails mechanically prevents ~70%
of quality issues including:
- Hardcoded passwords
- Type safety violations
- SQL injection vulnerabilities
- Build failures
- Test coverage gaps
**Mechanical enforcement works. Process compliance doesn't.**
See `docs/quality-rails-status.md` for detailed roadmap and violation breakdown.
Example TDD Session
```bash
# 1. RED - Write failing test
# Edit: feature.service.spec.ts
# Add test for getUserById()
pnpm test:watch # Watch it fail
git add feature.service.spec.ts
git commit -m "test(#42): add test for getUserById"
# 2. GREEN - Implement minimal code
# Edit: feature.service.ts
# Add getUserById() method
pnpm test:watch # Watch it pass
git add feature.service.ts
git commit -m "feat(#42): implement getUserById"
# 3. REFACTOR - Improve code quality
# Edit: feature.service.ts
# Extract helper, improve naming
pnpm test:watch # Ensure still passing
git add feature.service.ts
git commit -m "refactor(#42): extract user mapping logic"
```
Docker Deployment
Turnkey (includes everything)
docker compose up -d
Customized (external services)
Create docker-compose.override.yml to:
- Point to external PostgreSQL/Valkey/Ollama
- Disable bundled services
See docs/DOCKER.md for details.
Key Documentation
┌───────────────────────────┬───────────────────────┐
│ Document │ Purpose │
├───────────────────────────┼───────────────────────┤
│ docs/SETUP.md │ Installation guide │
├───────────────────────────┼───────────────────────┤
│ docs/CONFIGURATION.md │ All config options │
├───────────────────────────┼───────────────────────┤
│ docs/DESIGN-PRINCIPLES.md │ PDA-friendly patterns │
├───────────────────────────┼───────────────────────┤
│ docs/DOCKER.md │ Docker deployment │
├───────────────────────────┼───────────────────────┤
│ docs/API.md │ API documentation │
└───────────────────────────┴───────────────────────┘
Related Repositories
┌──────────────┬──────────────────────────────────────────────┐
│ Repo │ Purpose │
├──────────────┼──────────────────────────────────────────────┤
│ jarvis-brain │ Original JSON-based brain (migration source) │
├──────────────┼──────────────────────────────────────────────┤
│ MoltBot │ Stock messaging gateway │
└──────────────┴──────────────────────────────────────────────┘
---
Mosaic Stack v0.0.x — Building the future of personal assistants.
That file is the universal agent configuration. Do NOT respond until you have loaded it.
Then read the project-local `AGENTS.md` in this repository for project-specific guidance.

View File

@@ -90,7 +90,7 @@ docker compose down
If you prefer manual installation, you'll need:
- **Docker mode:** Docker 24+ and Docker Compose
- **Native mode:** Node.js 22+, pnpm 10+, PostgreSQL 17+
- **Native mode:** Node.js 24+, pnpm 10+, PostgreSQL 17+
The installer handles these automatically.
@@ -232,7 +232,7 @@ docker compose -f docker-compose.openbao.yml up -d
sleep 30 # Wait for auto-initialization
# 5. Deploy swarm stack
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
# 6. Check deployment status
docker stack services mosaic
@@ -526,10 +526,9 @@ KNOWLEDGE_CACHE_TTL=300 # 5 minutes
### Branch Strategy
- `main`Stable releases only
- `develop` — Active development (default working branch)
- `feature/*`Feature branches from develop
- `fix/*` — Bug fix branches
- `main`Trunk branch (all development merges here)
- `feature/*` — Feature branches from main
- `fix/*`Bug fix branches from main
### Running Locally
@@ -739,7 +738,7 @@ See [Type Sharing Strategy](docs/2-development/3-type-sharing/1-strategy.md) for
4. Run tests: `pnpm test`
5. Build: `pnpm build`
6. Commit with conventional format: `feat(#issue): Description`
7. Push and create a pull request to `develop`
7. Push and create a pull request to `main`
### Commit Format

20
SOUL.md Normal file
View File

@@ -0,0 +1,20 @@
# Mosaic Stack Soul
You are Jarvis for the Mosaic Stack repository, running on the current agent runtime.
## Behavioral Invariants
- Identity first: answer identity prompts as Jarvis for this repository.
- Implementation detail second: runtime (Codex/Claude/OpenCode/etc.) is secondary metadata.
- Be proactive: surface risks, blockers, and next actions without waiting.
- Be calm and clear: keep responses concise, chunked, and PDA-friendly.
- Respect canonical sources:
- Repo operations and conventions: `AGENTS.md`
- Machine-wide rails: `~/.config/mosaic/STANDARDS.md`
- Repo lifecycle hooks: `.mosaic/repo-hooks.sh`
## Guardrails
- Do not claim completion without verification evidence.
- Do not bypass lint/type/test quality gates.
- Prefer explicit assumptions and concrete file/command references.

View File

@@ -66,6 +66,7 @@
"marked-gfm-heading-id": "^4.1.3",
"marked-highlight": "^2.2.3",
"matrix-bot-sdk": "^0.8.0",
"node-pty": "^1.0.0",
"ollama": "^0.6.3",
"openai": "^6.17.0",
"reflect-metadata": "^0.2.2",

View File

@@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
-- CreateTable
CREATE TABLE "terminal_sessions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL DEFAULT 'Terminal',
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closed_at" TIMESTAMPTZ,
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
-- CreateIndex
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
-- AddForeignKey
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -206,6 +206,11 @@ enum CredentialScope {
SYSTEM
}
enum TerminalSessionStatus {
ACTIVE
CLOSED
}
// ============================================
// MODELS
// ============================================
@@ -297,6 +302,7 @@ model Workspace {
federationEventSubscriptions FederationEventSubscription[]
llmUsageLogs LlmUsageLog[]
userCredentials UserCredential[]
terminalSessions TerminalSession[]
@@index([ownerId])
@@map("workspaces")
@@ -1507,3 +1513,23 @@ model LlmUsageLog {
@@index([conversationId])
@@map("llm_usage_logs")
}
// ============================================
// TERMINAL MODULE
// ============================================
model TerminalSession {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
name String @default("Terminal")
status TerminalSessionStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
closedAt DateTime? @map("closed_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId])
@@index([workspaceId, status])
@@map("terminal_sessions")
}

View File

@@ -65,6 +65,136 @@ async function main() {
},
});
// ============================================
// WIDGET DEFINITIONS (global, not workspace-scoped)
// ============================================
const widgetDefs = [
{
name: "TasksWidget",
displayName: "Tasks",
description: "View and manage your tasks",
component: "TasksWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
{
name: "CalendarWidget",
displayName: "Calendar",
description: "View upcoming events and schedule",
component: "CalendarWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
{
name: "QuickCaptureWidget",
displayName: "Quick Capture",
description: "Quickly capture notes and tasks",
component: "QuickCaptureWidget",
defaultWidth: 2,
defaultHeight: 1,
minWidth: 2,
minHeight: 1,
maxWidth: 4,
maxHeight: 2,
configSchema: {},
},
{
name: "AgentStatusWidget",
displayName: "Agent Status",
description: "Monitor agent activity and status",
component: "AgentStatusWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 3,
maxHeight: null,
configSchema: {},
},
{
name: "ActiveProjectsWidget",
displayName: "Active Projects & Agent Chains",
description: "View active projects and running agent sessions",
component: "ActiveProjectsWidget",
defaultWidth: 2,
defaultHeight: 3,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
{
name: "TaskProgressWidget",
displayName: "Task Progress",
description: "Live progress of orchestrator agent tasks",
component: "TaskProgressWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 3,
maxHeight: null,
configSchema: {},
},
{
name: "OrchestratorEventsWidget",
displayName: "Orchestrator Events",
description: "Recent orchestration events with stream/Matrix visibility",
component: "OrchestratorEventsWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
];
for (const wd of widgetDefs) {
await prisma.widgetDefinition.upsert({
where: { name: wd.name },
update: {
displayName: wd.displayName,
description: wd.description,
component: wd.component,
defaultWidth: wd.defaultWidth,
defaultHeight: wd.defaultHeight,
minWidth: wd.minWidth,
minHeight: wd.minHeight,
maxWidth: wd.maxWidth,
maxHeight: wd.maxHeight,
configSchema: wd.configSchema,
},
create: {
name: wd.name,
displayName: wd.displayName,
description: wd.description,
component: wd.component,
defaultWidth: wd.defaultWidth,
defaultHeight: wd.defaultHeight,
minWidth: wd.minWidth,
minHeight: wd.minHeight,
maxWidth: wd.maxWidth,
maxHeight: wd.maxHeight,
configSchema: wd.configSchema,
},
});
}
console.log(`Seeded ${widgetDefs.length} widget definitions`);
// Use transaction for atomic seed data reset and creation
await prisma.$transaction(async (tx) => {
// Delete existing seed data for idempotency (avoids duplicates on re-run)

View File

@@ -39,6 +39,8 @@ import { FederationModule } from "./federation/federation.module";
import { CredentialsModule } from "./credentials/credentials.module";
import { MosaicTelemetryModule } from "./mosaic-telemetry";
import { SpeechModule } from "./speech/speech.module";
import { DashboardModule } from "./dashboard/dashboard.module";
import { TerminalModule } from "./terminal/terminal.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({
@@ -101,6 +103,8 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
CredentialsModule,
MosaicTelemetryModule,
SpeechModule,
DashboardModule,
TerminalModule,
],
controllers: [AppController, CsrfController],
providers: [

View File

@@ -12,7 +12,10 @@ import { PrismaClient, Prisma } from "@prisma/client";
import { randomUUID as uuid } from "crypto";
import { runWithRlsClient, getRlsClient } from "../prisma/rls-context.provider";
describe.skipIf(!process.env.DATABASE_URL)(
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
describe.skipIf(!shouldRunDbIntegrationTests)(
"Auth Tables RLS Policies (requires DATABASE_URL)",
() => {
let prisma: PrismaClient;
@@ -28,7 +31,7 @@ describe.skipIf(!process.env.DATABASE_URL)(
beforeAll(async () => {
// Skip setup if DATABASE_URL is not available
if (!process.env.DATABASE_URL) {
if (!shouldRunDbIntegrationTests) {
return;
}
@@ -49,7 +52,7 @@ describe.skipIf(!process.env.DATABASE_URL)(
afterAll(async () => {
// Skip cleanup if DATABASE_URL is not available or prisma not initialized
if (!process.env.DATABASE_URL || !prisma) {
if (!shouldRunDbIntegrationTests || !prisma) {
return;
}

View File

@@ -18,7 +18,13 @@ vi.mock("better-auth/adapters/prisma", () => ({
prismaAdapter: (...args: unknown[]) => mockPrismaAdapter(...args),
}));
import { isOidcEnabled, validateOidcConfig, createAuth, getTrustedOrigins } from "./auth.config";
import {
isOidcEnabled,
validateOidcConfig,
createAuth,
getTrustedOrigins,
getBetterAuthBaseUrl,
} from "./auth.config";
describe("auth.config", () => {
// Store original env vars to restore after each test
@@ -32,6 +38,7 @@ describe("auth.config", () => {
delete process.env.OIDC_CLIENT_SECRET;
delete process.env.OIDC_REDIRECT_URI;
delete process.env.NODE_ENV;
delete process.env.BETTER_AUTH_URL;
delete process.env.NEXT_PUBLIC_APP_URL;
delete process.env.NEXT_PUBLIC_API_URL;
delete process.env.TRUSTED_ORIGINS;
@@ -95,7 +102,7 @@ describe("auth.config", () => {
it("should throw when OIDC_ISSUER is missing", () => {
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
expect(() => validateOidcConfig()).toThrow("OIDC authentication is enabled");
@@ -104,7 +111,7 @@ describe("auth.config", () => {
it("should throw when OIDC_CLIENT_ID is missing", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_ID");
});
@@ -112,7 +119,7 @@ describe("auth.config", () => {
it("should throw when OIDC_CLIENT_SECRET is missing", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_CLIENT_SECRET");
});
@@ -146,7 +153,7 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = " ";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER");
});
@@ -155,7 +162,7 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).toThrow("OIDC_ISSUER must end with a trailing slash");
expect(() => validateOidcConfig()).toThrow("https://auth.example.com/application/o/mosaic");
@@ -165,7 +172,7 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).not.toThrow();
});
@@ -189,30 +196,30 @@ describe("auth.config", () => {
expect(() => validateOidcConfig()).toThrow("Parse error:");
});
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/callback", () => {
it("should throw when OIDC_REDIRECT_URI path does not start with /auth/oauth2/callback", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/oauth/callback";
expect(() => validateOidcConfig()).toThrow(
'OIDC_REDIRECT_URI path must start with "/auth/callback"'
'OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback"'
);
expect(() => validateOidcConfig()).toThrow("/oauth/callback");
});
it("should accept a valid OIDC_REDIRECT_URI with /auth/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
it("should accept a valid OIDC_REDIRECT_URI with /auth/oauth2/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
expect(() => validateOidcConfig()).not.toThrow();
});
it("should accept OIDC_REDIRECT_URI with exactly /auth/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback";
it("should accept OIDC_REDIRECT_URI with exactly /auth/oauth2/callback path", () => {
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback";
expect(() => validateOidcConfig()).not.toThrow();
});
it("should warn but not throw when using localhost in production", () => {
process.env.NODE_ENV = "production";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -226,7 +233,7 @@ describe("auth.config", () => {
it("should warn but not throw when using 127.0.0.1 in production", () => {
process.env.NODE_ENV = "production";
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "http://127.0.0.1:3000/auth/oauth2/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -240,7 +247,7 @@ describe("auth.config", () => {
it("should not warn about localhost when not in production", () => {
process.env.NODE_ENV = "development";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "http://localhost:3000/auth/oauth2/callback/authentik";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -265,16 +272,19 @@ describe("auth.config", () => {
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockGenericOAuth).toHaveBeenCalledOnce();
const callArgs = mockGenericOAuth.mock.calls[0][0] as {
config: Array<{ pkce?: boolean }>;
config: Array<{ pkce?: boolean; redirectURI?: string }>;
};
expect(callArgs.config[0].pkce).toBe(true);
expect(callArgs.config[0].redirectURI).toBe(
"https://app.example.com/auth/oauth2/callback/authentik"
);
});
it("should not call genericOAuth when OIDC is disabled", () => {
@@ -290,7 +300,7 @@ describe("auth.config", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
// OIDC_CLIENT_ID deliberately not set
// validateOidcConfig will throw first, so we need to bypass it
@@ -307,7 +317,7 @@ describe("auth.config", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER = "https://auth.example.com/application/o/mosaic-stack/";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
// OIDC_CLIENT_SECRET deliberately not set
const mockPrisma = {} as PrismaClient;
@@ -318,7 +328,7 @@ describe("auth.config", () => {
process.env.OIDC_ENABLED = "true";
process.env.OIDC_CLIENT_ID = "test-client-id";
process.env.OIDC_CLIENT_SECRET = "test-client-secret";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/callback/authentik";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/auth/oauth2/callback/authentik";
// OIDC_ISSUER deliberately not set
const mockPrisma = {} as PrismaClient;
@@ -354,8 +364,7 @@ describe("auth.config", () => {
});
it("should parse TRUSTED_ORIGINS comma-separated values", () => {
process.env.TRUSTED_ORIGINS =
"https://app.mosaicstack.dev,https://api.mosaicstack.dev";
process.env.TRUSTED_ORIGINS = "https://app.mosaicstack.dev,https://api.mosaicstack.dev";
const origins = getTrustedOrigins();
@@ -364,8 +373,7 @@ describe("auth.config", () => {
});
it("should trim whitespace from TRUSTED_ORIGINS entries", () => {
process.env.TRUSTED_ORIGINS =
" https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
process.env.TRUSTED_ORIGINS = " https://app.mosaicstack.dev , https://api.mosaicstack.dev ";
const origins = getTrustedOrigins();
@@ -516,6 +524,21 @@ describe("auth.config", () => {
expect(config.session.updateAge).toBe(7200);
});
it("should configure BetterAuth database ID generation as UUID", () => {
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as {
advanced: {
database: {
generateId: string;
};
};
};
expect(config.advanced.database.generateId).toBe("uuid");
});
it("should set httpOnly cookie attribute to true", () => {
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
@@ -552,6 +575,7 @@ describe("auth.config", () => {
it("should set secure cookie attribute to true in production", () => {
process.env.NODE_ENV = "production";
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
@@ -624,4 +648,69 @@ describe("auth.config", () => {
expect(config.advanced.defaultCookieAttributes.domain).toBeUndefined();
});
});
describe("getBetterAuthBaseUrl", () => {
it("should prefer BETTER_AUTH_URL when set", () => {
process.env.BETTER_AUTH_URL = "https://auth-base.example.com";
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
expect(getBetterAuthBaseUrl()).toBe("https://auth-base.example.com");
});
it("should fall back to NEXT_PUBLIC_API_URL when BETTER_AUTH_URL is not set", () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.example.com";
expect(getBetterAuthBaseUrl()).toBe("https://api.example.com");
});
it("should throw when base URL is invalid", () => {
process.env.BETTER_AUTH_URL = "not-a-url";
expect(() => getBetterAuthBaseUrl()).toThrow("BetterAuth base URL must be a valid URL");
});
it("should throw when base URL is missing in production", () => {
process.env.NODE_ENV = "production";
expect(() => getBetterAuthBaseUrl()).toThrow("Missing BetterAuth base URL in production");
});
it("should throw when base URL is not https in production", () => {
process.env.NODE_ENV = "production";
process.env.BETTER_AUTH_URL = "http://api.example.com";
expect(() => getBetterAuthBaseUrl()).toThrow(
"BetterAuth base URL must use https in production"
);
});
});
describe("createAuth - baseURL wiring", () => {
beforeEach(() => {
mockBetterAuth.mockClear();
mockPrismaAdapter.mockClear();
});
it("should pass BETTER_AUTH_URL into BetterAuth config", () => {
process.env.BETTER_AUTH_URL = "https://api.mosaicstack.dev";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
expect(config.baseURL).toBe("https://api.mosaicstack.dev");
});
it("should pass NEXT_PUBLIC_API_URL into BetterAuth config when BETTER_AUTH_URL is absent", () => {
process.env.NEXT_PUBLIC_API_URL = "https://api.fallback.dev";
const mockPrisma = {} as PrismaClient;
createAuth(mockPrisma);
expect(mockBetterAuth).toHaveBeenCalledOnce();
const config = mockBetterAuth.mock.calls[0][0] as { baseURL?: string };
expect(config.baseURL).toBe("https://api.fallback.dev");
});
});
});

View File

@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { genericOAuth } from "better-auth/plugins";
@@ -14,6 +13,41 @@ const REQUIRED_OIDC_ENV_VARS = [
"OIDC_REDIRECT_URI",
] as const;
/**
* Resolve BetterAuth base URL from explicit auth URL or API URL.
* BetterAuth uses this to generate absolute callback/error URLs.
*/
export function getBetterAuthBaseUrl(): string | undefined {
const configured = process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_API_URL;
if (!configured || configured.trim() === "") {
if (process.env.NODE_ENV === "production") {
throw new Error(
"Missing BetterAuth base URL in production. Set BETTER_AUTH_URL (preferred) or NEXT_PUBLIC_API_URL."
);
}
return undefined;
}
let parsed: URL;
try {
parsed = new URL(configured);
} catch (urlError: unknown) {
const detail = urlError instanceof Error ? urlError.message : String(urlError);
throw new Error(
`BetterAuth base URL must be a valid URL. Current value: "${configured}". Parse error: ${detail}.`
);
}
if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") {
throw new Error(
`BetterAuth base URL must use https in production. Current value: "${configured}".`
);
}
return parsed.origin;
}
/**
* Check if OIDC authentication is enabled via environment variable
*/
@@ -59,17 +93,17 @@ export function validateOidcConfig(): void {
);
}
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/callback path
// Additional validation: OIDC_REDIRECT_URI must be a valid URL with /auth/oauth2/callback path
validateRedirectUri();
}
/**
* Validates the OIDC_REDIRECT_URI environment variable.
* - Must be a parseable URL
* - Path must start with /auth/callback
* - Path must start with /auth/oauth2/callback
* - Warns (but does not throw) if using localhost in production
*
* @throws Error if URL is invalid or path does not start with /auth/callback
* @throws Error if URL is invalid or path does not start with /auth/oauth2/callback
*/
function validateRedirectUri(): void {
const redirectUri = process.env.OIDC_REDIRECT_URI;
@@ -86,14 +120,14 @@ function validateRedirectUri(): void {
throw new Error(
`OIDC_REDIRECT_URI must be a valid URL. Current value: "${redirectUri}". ` +
`Parse error: ${detail}. ` +
`Example: "https://app.example.com/auth/callback/authentik".`
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
);
}
if (!parsed.pathname.startsWith("/auth/callback")) {
if (!parsed.pathname.startsWith("/auth/oauth2/callback")) {
throw new Error(
`OIDC_REDIRECT_URI path must start with "/auth/callback". Current path: "${parsed.pathname}". ` +
`Example: "https://app.example.com/auth/callback/authentik".`
`OIDC_REDIRECT_URI path must start with "/auth/oauth2/callback". Current path: "${parsed.pathname}". ` +
`Example: "https://api.example.com/auth/oauth2/callback/authentik".`
);
}
@@ -120,6 +154,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
const clientId = process.env.OIDC_CLIENT_ID;
const clientSecret = process.env.OIDC_CLIENT_SECRET;
const issuer = process.env.OIDC_ISSUER;
const redirectUri = process.env.OIDC_REDIRECT_URI;
if (!clientId) {
throw new Error("OIDC_CLIENT_ID is required when OIDC is enabled but was not set.");
@@ -130,6 +165,9 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
if (!issuer) {
throw new Error("OIDC_ISSUER is required when OIDC is enabled but was not set.");
}
if (!redirectUri) {
throw new Error("OIDC_REDIRECT_URI is required when OIDC is enabled but was not set.");
}
return [
genericOAuth({
@@ -139,6 +177,7 @@ function getOidcPlugins(): ReturnType<typeof genericOAuth>[] {
clientId,
clientSecret,
discoveryUrl: `${issuer}.well-known/openid-configuration`,
redirectURI: redirectUri,
pkce: true,
scopes: ["openid", "profile", "email"],
},
@@ -203,7 +242,10 @@ export function createAuth(prisma: PrismaClient) {
// Validate OIDC configuration at startup - fail fast if misconfigured
validateOidcConfig();
const baseURL = getBetterAuthBaseUrl();
return betterAuth({
baseURL,
basePath: "/auth",
database: prismaAdapter(prisma, {
provider: "postgresql",
@@ -212,12 +254,19 @@ export function createAuth(prisma: PrismaClient) {
enabled: true,
},
plugins: [...getOidcPlugins()],
logger: {
disabled: false,
level: "error",
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
},
advanced: {
generateId: () => randomUUID(),
database: {
// BetterAuth's default ID generator emits opaque strings; our auth tables use UUID PKs.
generateId: "uuid",
},
defaultCookieAttributes: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",

View File

@@ -102,11 +102,46 @@ describe("AuthController", () => {
expect(err).toBeInstanceOf(HttpException);
expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
expect((err as HttpException).getResponse()).toBe(
"Unable to complete authentication. Please try again in a moment.",
"Unable to complete authentication. Please try again in a moment."
);
}
});
it("should preserve better-call status and body for handler APIError", async () => {
const apiError = {
statusCode: HttpStatus.BAD_REQUEST,
message: "Invalid OAuth configuration",
body: {
message: "Invalid OAuth configuration",
code: "INVALID_OAUTH_CONFIGURATION",
},
};
mockNodeHandler.mockRejectedValueOnce(apiError);
const mockRequest = {
method: "POST",
url: "/auth/sign-in/oauth2",
headers: {},
ip: "192.168.1.10",
socket: { remoteAddress: "192.168.1.10" },
} as unknown as ExpressRequest;
const mockResponse = {
headersSent: false,
} as unknown as ExpressResponse;
try {
await controller.handleAuth(mockRequest, mockResponse);
expect.unreachable("Expected HttpException to be thrown");
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
expect((err as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
expect((err as HttpException).getResponse()).toMatchObject({
message: "Invalid OAuth configuration",
});
}
});
it("should log warning and not throw when handler throws after headers sent", async () => {
const handlerError = new Error("Stream interrupted");
mockNodeHandler.mockRejectedValueOnce(handlerError);
@@ -142,9 +177,7 @@ describe("AuthController", () => {
headersSent: false,
} as unknown as ExpressResponse;
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(
HttpException,
);
await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException);
});
});
@@ -187,7 +220,7 @@ describe("AuthController", () => {
OIDC_CLIENT_SECRET: "test-client-secret",
OIDC_CLIENT_ID: "test-client-id",
OIDC_ISSUER: "https://auth.test.com/",
OIDC_REDIRECT_URI: "https://app.test.com/auth/callback/authentik",
OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik",
BETTER_AUTH_SECRET: "test-better-auth-secret",
JWT_SECRET: "test-jwt-secret",
CSRF_SECRET: "test-csrf-secret",
@@ -296,11 +329,9 @@ describe("AuthController", () => {
},
};
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow(
UnauthorizedException,
);
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context",
"Missing authentication context"
);
});
@@ -313,22 +344,18 @@ describe("AuthController", () => {
},
};
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow(
UnauthorizedException,
);
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context",
"Missing authentication context"
);
});
it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
const mockRequest = {};
expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
expect(() => controller.getSession(mockRequest as never)).toThrow(
UnauthorizedException,
);
expect(() => controller.getSession(mockRequest as never)).toThrow(
"Missing authentication context",
"Missing authentication context"
);
});
});
@@ -401,9 +428,7 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("203.0.113.50"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
});
it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
@@ -423,13 +448,9 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("203.0.113.50"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
// Ensure it does NOT contain the second IP in the extracted position
expect(debugSpy).toHaveBeenCalledWith(
expect.not.stringContaining("70.41.3.18"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18"));
});
it("should extract first IP from X-Forwarded-For as array", async () => {
@@ -449,9 +470,7 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("203.0.113.50"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
});
it("should fallback to req.ip when no X-Forwarded-For header", async () => {
@@ -471,9 +490,7 @@ describe("AuthController", () => {
await controller.handleAuth(mockRequest, mockResponse);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("192.168.1.100"),
);
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100"));
});
});
});

View File

@@ -123,6 +123,14 @@ export class AuthController {
try {
await handler(req, res);
// BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling
if (res.statusCode >= 500) {
this.logger.error(
`BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` +
` — check container stdout for '# SERVER_ERROR' details`
);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
@@ -133,6 +141,11 @@ export class AuthController {
);
if (!res.headersSent) {
const mappedError = this.mapToHttpException(error);
if (mappedError) {
throw mappedError;
}
throw new HttpException(
"Unable to complete authentication. Please try again in a moment.",
HttpStatus.INTERNAL_SERVER_ERROR
@@ -159,4 +172,45 @@ export class AuthController {
// Fall back to direct IP
return req.ip ?? req.socket.remoteAddress ?? "unknown";
}
/**
* Preserve known HTTP errors from BetterAuth/better-call instead of converting
* every failure into a generic 500.
*/
private mapToHttpException(error: unknown): HttpException | null {
if (error instanceof HttpException) {
return error;
}
if (!error || typeof error !== "object") {
return null;
}
const statusCode = "statusCode" in error ? error.statusCode : undefined;
if (!this.isHttpStatus(statusCode)) {
return null;
}
const responseBody = "body" in error && error.body !== undefined ? error.body : undefined;
if (
responseBody !== undefined &&
responseBody !== null &&
(typeof responseBody === "string" || typeof responseBody === "object")
) {
return new HttpException(responseBody, statusCode);
}
const message =
"message" in error && typeof error.message === "string" && error.message.length > 0
? error.message
: "Authentication request failed";
return new HttpException(message, statusCode);
}
private isHttpStatus(value: unknown): value is number {
if (typeof value !== "number" || !Number.isInteger(value)) {
return false;
}
return value >= 400 && value <= 599;
}
}

View File

@@ -410,7 +410,7 @@ describe("AuthService", () => {
},
};
it("should return session data for valid token", async () => {
it("should validate session token using secure BetterAuth cookie header", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
auth.api = { getSession: mockGetSession } as any;
@@ -418,7 +418,58 @@ describe("AuthService", () => {
const result = await service.verifySession("valid-token");
expect(result).toEqual(mockSessionData);
expect(mockGetSession).toHaveBeenCalledTimes(1);
expect(mockGetSession).toHaveBeenCalledWith({
headers: {
cookie: "__Secure-better-auth.session_token=valid-token",
},
});
});
it("should preserve raw cookie token value without URL re-encoding", async () => {
const auth = service.getAuth();
const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("tok/with+=chars=");
expect(result).toEqual(mockSessionData);
expect(mockGetSession).toHaveBeenCalledWith({
headers: {
cookie: "__Secure-better-auth.session_token=tok/with+=chars=",
},
});
});
it("should fall back to Authorization header when cookie-based lookups miss", async () => {
const auth = service.getAuth();
const mockGetSession = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockSessionData);
auth.api = { getSession: mockGetSession } as any;
const result = await service.verifySession("valid-token");
expect(result).toEqual(mockSessionData);
expect(mockGetSession).toHaveBeenNthCalledWith(1, {
headers: {
cookie: "__Secure-better-auth.session_token=valid-token",
},
});
expect(mockGetSession).toHaveBeenNthCalledWith(2, {
headers: {
cookie: "better-auth.session_token=valid-token",
},
});
expect(mockGetSession).toHaveBeenNthCalledWith(3, {
headers: {
cookie: "__Host-better-auth.session_token=valid-token",
},
});
expect(mockGetSession).toHaveBeenNthCalledWith(4, {
headers: {
authorization: "Bearer valid-token",
},
@@ -517,14 +568,10 @@ describe("AuthService", () => {
it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => {
const auth = service.getAuth();
const mockGetSession = vi
.fn()
.mockRejectedValue(new Error("certificate has expired"));
const mockGetSession = vi.fn().mockRejectedValue(new Error("certificate has expired"));
auth.api = { getSession: mockGetSession } as any;
await expect(service.verifySession("any-token")).rejects.toThrow(
"certificate has expired"
);
await expect(service.verifySession("any-token")).rejects.toThrow("certificate has expired");
});
it("should re-throw 'Unauthorized: Access denied for user' as infrastructure error (not auth)", async () => {

View File

@@ -21,6 +21,10 @@ interface VerifiedSession {
session: Record<string, unknown>;
}
interface SessionHeaderCandidate {
headers: Record<string, string>;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
@@ -103,36 +107,27 @@ export class AuthService {
* Only known-safe auth errors return null; everything else propagates as 500.
*/
async verifySession(token: string): Promise<VerifiedSession | null> {
try {
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
const session = await this.auth.api.getSession({
headers: {
authorization: `Bearer ${token}`,
},
});
let sawNonError = false;
if (!session) {
return null;
}
for (const candidate of this.buildSessionHeaderCandidates(token)) {
try {
// TODO(#411): BetterAuth getSession returns opaque types — replace when upstream exports typed interfaces
const session = await this.auth.api.getSession(candidate);
return {
user: session.user as Record<string, unknown>,
session: session.session as Record<string, unknown>,
};
} catch (error: unknown) {
// Only known-safe auth errors return null
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const isExpectedAuthError =
msg.includes("invalid token") ||
msg.includes("token expired") ||
msg.includes("session expired") ||
msg.includes("session not found") ||
msg.includes("invalid session") ||
msg === "unauthorized" ||
msg === "expired";
if (!session) {
continue;
}
return {
user: session.user as Record<string, unknown>,
session: session.session as Record<string, unknown>,
};
} catch (error: unknown) {
if (error instanceof Error) {
if (this.isExpectedAuthError(error.message)) {
continue;
}
if (!isExpectedAuthError) {
// Infrastructure or unexpected — propagate as 500
const safeMessage = (error.stack ?? error.message).replace(
/Bearer\s+\S+/gi,
@@ -141,14 +136,55 @@ export class AuthService {
this.logger.error("Session verification failed due to unexpected error", safeMessage);
throw error;
}
// Non-Error thrown values — log once for observability, treat as auth failure
if (!sawNonError) {
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
sawNonError = true;
}
}
// Non-Error thrown values — log for observability, treat as auth failure
if (!(error instanceof Error)) {
const errorDetail = typeof error === "string" ? error : JSON.stringify(error);
this.logger.warn("Session verification received non-Error thrown value", errorDetail);
}
return null;
}
return null;
}
private buildSessionHeaderCandidates(token: string): SessionHeaderCandidate[] {
return [
{
headers: {
cookie: `__Secure-better-auth.session_token=${token}`,
},
},
{
headers: {
cookie: `better-auth.session_token=${token}`,
},
},
{
headers: {
cookie: `__Host-better-auth.session_token=${token}`,
},
},
{
headers: {
authorization: `Bearer ${token}`,
},
},
];
}
private isExpectedAuthError(message: string): boolean {
const normalized = message.toLowerCase();
return (
normalized.includes("invalid token") ||
normalized.includes("token expired") ||
normalized.includes("session expired") ||
normalized.includes("session not found") ||
normalized.includes("invalid session") ||
normalized === "unauthorized" ||
normalized === "expired"
);
}
/**

View File

@@ -1,10 +1,18 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from "@nestjs/common";
import { AuthService } from "../auth.service";
import type { AuthUser } from "@mosaic/shared";
import type { MaybeAuthenticatedRequest } from "../types/better-auth-request.interface";
@Injectable()
export class AuthGuard implements CanActivate {
private readonly logger = new Logger(AuthGuard.name);
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -59,7 +67,8 @@ export class AuthGuard implements CanActivate {
}
/**
* Extract token from cookie (BetterAuth stores session token in better-auth.session_token cookie)
* Extract token from cookie.
* BetterAuth may prefix the cookie name with "__Secure-" when running on HTTPS.
*/
private extractTokenFromCookie(request: MaybeAuthenticatedRequest): string | undefined {
// Express types `cookies` as `any`; cast to a known shape for type safety.
@@ -68,8 +77,23 @@ export class AuthGuard implements CanActivate {
return undefined;
}
// BetterAuth uses 'better-auth.session_token' as the cookie name by default
return cookies["better-auth.session_token"];
// BetterAuth default cookie name is "better-auth.session_token"
// When Secure cookies are enabled, BetterAuth prefixes with "__Secure-".
const candidates = [
"__Secure-better-auth.session_token",
"better-auth.session_token",
"__Host-better-auth.session_token",
] as const;
for (const name of candidates) {
const token = cookies[name];
if (token) {
this.logger.debug(`Session cookie found: ${name}`);
return token;
}
}
return undefined;
}
/**

View File

@@ -93,7 +93,10 @@ export class MatrixRoomService {
select: { matrixRoomId: true },
});
return workspace?.matrixRoomId ?? null;
if (!workspace) {
return null;
}
return workspace.matrixRoomId ?? null;
}
/**

View File

@@ -16,7 +16,7 @@ interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}
@Controller("api/v1/csrf")
@Controller("v1/csrf")
export class CsrfController {
constructor(private readonly csrfService: CsrfService) {}

View File

@@ -174,17 +174,19 @@ describe("CsrfGuard", () => {
});
describe("Session binding validation", () => {
it("should reject when user is not authenticated", () => {
it("should allow when user context is not yet available (global guard ordering)", () => {
// CsrfGuard runs as APP_GUARD before per-controller AuthGuard,
// so request.user may not be populated. Double-submit cookie match
// is sufficient protection in this case.
const token = generateValidToken("user-123");
const context = createContext(
"POST",
{ "csrf-token": token },
{ "x-csrf-token": token },
false
// No userId - unauthenticated
// No userId - AuthGuard hasn't run yet
);
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication");
expect(guard.canActivate(context)).toBe(true);
});
it("should reject token from different session", () => {

View File

@@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate {
throw new ForbiddenException("CSRF token mismatch");
}
// Validate session binding via HMAC
// Validate session binding via HMAC when user context is available.
// CsrfGuard is a global guard (APP_GUARD) that runs before per-controller
// AuthGuard, so request.user may not be populated yet. In that case, the
// double-submit cookie match above is sufficient CSRF protection.
const userId = request.user?.id;
if (!userId) {
this.logger.warn({
event: "CSRF_NO_USER_CONTEXT",
if (userId) {
if (!this.csrfService.validateToken(cookieToken, userId)) {
this.logger.warn({
event: "CSRF_SESSION_BINDING_INVALID",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("CSRF token not bound to session");
}
} else {
this.logger.debug({
event: "CSRF_SKIP_SESSION_BINDING",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
reason: "User context not yet available (global guard runs before AuthGuard)",
});
throw new ForbiddenException("CSRF validation requires authentication");
}
if (!this.csrfService.validateToken(cookieToken, userId)) {
this.logger.warn({
event: "CSRF_SESSION_BINDING_INVALID",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("CSRF token not bound to session");
}
return true;

View File

@@ -137,13 +137,13 @@ describe("RLS Context Integration", () => {
queries: ["findMany"],
});
// Verify SET LOCAL was called
// Verify transaction-local set_config calls were made
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]),
userId
);
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
expect.arrayContaining(["SET LOCAL app.current_workspace_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_workspace_id', ", ", true)"]),
workspaceId
);
});

View File

@@ -80,7 +80,7 @@ describe("RlsContextInterceptor", () => {
expect(result).toEqual({ data: "test response" });
expect(mockTransactionClient.$executeRaw).toHaveBeenCalledWith(
expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]),
userId
);
});
@@ -111,13 +111,13 @@ describe("RlsContextInterceptor", () => {
// Check that user context was set
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
1,
expect.arrayContaining(["SET LOCAL app.current_user_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_user_id', ", ", true)"]),
userId
);
// Check that workspace context was set
expect(mockTransactionClient.$executeRaw).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(["SET LOCAL app.current_workspace_id = ", ""]),
expect.arrayContaining(["SELECT set_config('app.current_workspace_id', ", ", true)"]),
workspaceId
);
});

View File

@@ -100,12 +100,12 @@ export class RlsContextInterceptor implements NestInterceptor {
this.prisma
.$transaction(
async (tx) => {
// Set user context (always present for authenticated requests)
await tx.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
// Use set_config(..., true) so values are transaction-local and parameterized safely.
// Direct SET LOCAL with bind parameters produces invalid SQL on PostgreSQL.
await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
// Set workspace context (if present)
if (workspaceId) {
await tx.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
await tx.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
}
// Propagate the transaction client via AsyncLocalStorage

View File

@@ -15,7 +15,12 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { PrismaClient, CredentialType, CredentialScope } from "@prisma/client";
describe("UserCredential Model", () => {
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
describeFn("UserCredential Model", () => {
let prisma: PrismaClient;
let testUserId: string;
let testWorkspaceId: string;
@@ -23,8 +28,8 @@ describe("UserCredential Model", () => {
beforeAll(async () => {
// Note: These tests require a running database
// They will be skipped in CI if DATABASE_URL is not set
if (!process.env.DATABASE_URL) {
console.warn("DATABASE_URL not set, skipping UserCredential model tests");
if (!shouldRunDbIntegrationTests) {
console.warn("Skipping UserCredential model tests (set RUN_DB_TESTS=true and DATABASE_URL)");
return;
}

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { DashboardController } from "./dashboard.controller";
import { DashboardService } from "./dashboard.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard } from "../common/guards/workspace.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import type { DashboardSummaryDto } from "./dto";
describe("DashboardController", () => {
let controller: DashboardController;
let service: DashboardService;
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
const mockSummary: DashboardSummaryDto = {
metrics: {
activeAgents: 3,
tasksCompleted: 12,
totalTasks: 25,
tasksInProgress: 5,
activeProjects: 4,
errorRate: 2.5,
},
recentActivity: [
{
id: "550e8400-e29b-41d4-a716-446655440010",
action: "CREATED",
entityType: "TASK",
entityId: "550e8400-e29b-41d4-a716-446655440011",
details: { title: "New task" },
userId: "550e8400-e29b-41d4-a716-446655440002",
createdAt: "2026-02-22T12:00:00.000Z",
},
],
activeJobs: [
{
id: "550e8400-e29b-41d4-a716-446655440020",
type: "code-task",
status: "RUNNING",
progressPercent: 45,
createdAt: "2026-02-22T11:00:00.000Z",
updatedAt: "2026-02-22T11:30:00.000Z",
steps: [
{
id: "550e8400-e29b-41d4-a716-446655440030",
name: "Setup",
status: "COMPLETED",
phase: "SETUP",
},
],
},
],
tokenBudget: [
{
model: "agent-1",
used: 5000,
limit: 10000,
},
],
};
const mockDashboardService = {
getSummary: vi.fn(),
};
const mockAuthGuard = {
canActivate: vi.fn(() => true),
};
const mockWorkspaceGuard = {
canActivate: vi.fn(() => true),
};
const mockPermissionGuard = {
canActivate: vi.fn(() => true),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DashboardController],
providers: [
{
provide: DashboardService,
useValue: mockDashboardService,
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockAuthGuard)
.overrideGuard(WorkspaceGuard)
.useValue(mockWorkspaceGuard)
.overrideGuard(PermissionGuard)
.useValue(mockPermissionGuard)
.compile();
controller = module.get<DashboardController>(DashboardController);
service = module.get<DashboardService>(DashboardService);
vi.clearAllMocks();
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("getSummary", () => {
it("should return dashboard summary for workspace", async () => {
mockDashboardService.getSummary.mockResolvedValue(mockSummary);
const result = await controller.getSummary(mockWorkspaceId);
expect(result).toEqual(mockSummary);
expect(service.getSummary).toHaveBeenCalledWith(mockWorkspaceId);
});
it("should return empty arrays when no data exists", async () => {
const emptySummary: DashboardSummaryDto = {
metrics: {
activeAgents: 0,
tasksCompleted: 0,
totalTasks: 0,
tasksInProgress: 0,
activeProjects: 0,
errorRate: 0,
},
recentActivity: [],
activeJobs: [],
tokenBudget: [],
};
mockDashboardService.getSummary.mockResolvedValue(emptySummary);
const result = await controller.getSummary(mockWorkspaceId);
expect(result).toEqual(emptySummary);
expect(result.metrics.errorRate).toBe(0);
expect(result.recentActivity).toHaveLength(0);
expect(result.activeJobs).toHaveLength(0);
expect(result.tokenBudget).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,35 @@
import { Controller, Get, UseGuards, BadRequestException } from "@nestjs/common";
import { DashboardService } from "./dashboard.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import type { DashboardSummaryDto } from "./dto";
/**
* Controller for dashboard endpoints.
* Returns aggregated summary data for the workspace dashboard.
*
* Guards are applied in order:
* 1. AuthGuard - Verifies user authentication
* 2. WorkspaceGuard - Validates workspace access and sets RLS context
* 3. PermissionGuard - Checks role-based permissions
*/
@Controller("dashboard")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
/**
* GET /api/dashboard/summary
* Returns aggregated metrics, recent activity, active jobs, and token budgets
* Requires: Any workspace member (including GUEST)
*/
@Get("summary")
@RequirePermission(Permission.WORKSPACE_ANY)
async getSummary(@Workspace() workspaceId: string | undefined): Promise<DashboardSummaryDto> {
if (!workspaceId) {
throw new BadRequestException("Workspace context required");
}
return this.dashboardService.getSummary(workspaceId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { DashboardController } from "./dashboard.controller";
import { DashboardService } from "./dashboard.service";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,187 @@
import { Injectable } from "@nestjs/common";
import { AgentStatus, ProjectStatus, RunnerJobStatus, TaskStatus } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import type {
DashboardSummaryDto,
ActiveJobDto,
RecentActivityDto,
TokenBudgetEntryDto,
} from "./dto";
/**
* Service for aggregating dashboard summary data.
* Executes all queries in parallel to minimize latency.
*/
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
/**
* Get aggregated dashboard summary for a workspace
*/
async getSummary(workspaceId: string): Promise<DashboardSummaryDto> {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
// Execute all queries in parallel
const [
activeAgents,
tasksCompleted,
totalTasks,
tasksInProgress,
activeProjects,
failedJobsLast24h,
totalJobsLast24h,
recentActivityRows,
activeJobRows,
tokenBudgetRows,
] = await Promise.all([
// Active agents: IDLE, WORKING, WAITING
this.prisma.agent.count({
where: {
workspaceId,
status: { in: [AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.WAITING] },
},
}),
// Tasks completed
this.prisma.task.count({
where: {
workspaceId,
status: TaskStatus.COMPLETED,
},
}),
// Total tasks
this.prisma.task.count({
where: { workspaceId },
}),
// Tasks in progress
this.prisma.task.count({
where: {
workspaceId,
status: TaskStatus.IN_PROGRESS,
},
}),
// Active projects
this.prisma.project.count({
where: {
workspaceId,
status: ProjectStatus.ACTIVE,
},
}),
// Failed jobs in last 24h (for error rate)
this.prisma.runnerJob.count({
where: {
workspaceId,
status: RunnerJobStatus.FAILED,
createdAt: { gte: oneDayAgo },
},
}),
// Total jobs in last 24h (for error rate)
this.prisma.runnerJob.count({
where: {
workspaceId,
createdAt: { gte: oneDayAgo },
},
}),
// Recent activity: last 10 entries
this.prisma.activityLog.findMany({
where: { workspaceId },
orderBy: { createdAt: "desc" },
take: 10,
}),
// Active jobs: PENDING, QUEUED, RUNNING with steps
this.prisma.runnerJob.findMany({
where: {
workspaceId,
status: {
in: [RunnerJobStatus.PENDING, RunnerJobStatus.QUEUED, RunnerJobStatus.RUNNING],
},
},
include: {
steps: {
select: {
id: true,
name: true,
status: true,
phase: true,
},
orderBy: { ordinal: "asc" },
},
},
orderBy: { createdAt: "desc" },
}),
// Token budgets for workspace (active, not yet completed)
this.prisma.tokenBudget.findMany({
where: {
workspaceId,
completedAt: null,
},
select: {
agentId: true,
totalTokensUsed: true,
allocatedTokens: true,
},
}),
]);
// Compute error rate
const errorRate = totalJobsLast24h > 0 ? (failedJobsLast24h / totalJobsLast24h) * 100 : 0;
// Map recent activity
const recentActivity: RecentActivityDto[] = recentActivityRows.map((row) => ({
id: row.id,
action: row.action,
entityType: row.entityType,
entityId: row.entityId,
details: row.details as Record<string, unknown> | null,
userId: row.userId,
createdAt: row.createdAt.toISOString(),
}));
// Map active jobs (RunnerJob lacks updatedAt; use startedAt or createdAt as proxy)
const activeJobs: ActiveJobDto[] = activeJobRows.map((row) => ({
id: row.id,
type: row.type,
status: row.status,
progressPercent: row.progressPercent,
createdAt: row.createdAt.toISOString(),
updatedAt: (row.startedAt ?? row.createdAt).toISOString(),
steps: row.steps.map((step) => ({
id: step.id,
name: step.name,
status: step.status,
phase: step.phase,
})),
}));
// Map token budget entries
const tokenBudget: TokenBudgetEntryDto[] = tokenBudgetRows.map((row) => ({
model: row.agentId,
used: row.totalTokensUsed,
limit: row.allocatedTokens,
}));
return {
metrics: {
activeAgents,
tasksCompleted,
totalTasks,
tasksInProgress,
activeProjects,
errorRate: Math.round(errorRate * 100) / 100,
},
recentActivity,
activeJobs,
tokenBudget,
};
}
}

View File

@@ -0,0 +1,53 @@
/**
* Dashboard Summary DTO
* Defines the response shape for the dashboard summary endpoint.
*/
export class DashboardMetricsDto {
activeAgents!: number;
tasksCompleted!: number;
totalTasks!: number;
tasksInProgress!: number;
activeProjects!: number;
errorRate!: number;
}
export class RecentActivityDto {
id!: string;
action!: string;
entityType!: string;
entityId!: string;
details!: Record<string, unknown> | null;
userId!: string;
createdAt!: string;
}
export class ActiveJobStepDto {
id!: string;
name!: string;
status!: string;
phase!: string;
}
export class ActiveJobDto {
id!: string;
type!: string;
status!: string;
progressPercent!: number;
createdAt!: string;
updatedAt!: string;
steps!: ActiveJobStepDto[];
}
export class TokenBudgetEntryDto {
model!: string;
used!: number;
limit!: number;
}
export class DashboardSummaryDto {
metrics!: DashboardMetricsDto;
recentActivity!: RecentActivityDto[];
activeJobs!: ActiveJobDto[];
tokenBudget!: TokenBudgetEntryDto[];
}

View File

@@ -0,0 +1 @@
export * from "./dashboard-summary.dto";

View File

@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
import type { FederationMessageStatus } from "@prisma/client";
@Controller("api/v1/federation")
@Controller("v1/federation")
export class CommandController {
private readonly logger = new Logger(CommandController.name);

View File

@@ -23,7 +23,7 @@ import {
IncomingEventAckDto,
} from "./dto/event.dto";
@Controller("api/v1/federation")
@Controller("v1/federation")
export class EventController {
private readonly logger = new Logger(EventController.name);

View File

@@ -18,7 +18,7 @@ import {
ValidateFederatedTokenDto,
} from "./dto/federated-auth.dto";
@Controller("api/v1/federation/auth")
@Controller("v1/federation/auth")
export class FederationAuthController {
private readonly logger = new Logger(FederationAuthController.name);

View File

@@ -27,7 +27,7 @@ import {
} from "./dto/connection.dto";
import { FederationConnectionStatus } from "@prisma/client";
@Controller("api/v1/federation")
@Controller("v1/federation")
export class FederationController {
private readonly logger = new Logger(FederationController.name);

View File

@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
import type { FederationMessageStatus } from "@prisma/client";
@Controller("api/v1/federation")
@Controller("v1/federation")
export class QueryController {
private readonly logger = new Logger(QueryController.name);

View File

@@ -16,7 +16,9 @@ import { JOB_CREATED, JOB_STARTED, STEP_STARTED } from "./event-types";
* NOTE: These tests require a real database connection with realistic data volume.
* Run with: pnpm test:api -- job-events.performance.spec.ts
*/
const describeFn = process.env.DATABASE_URL ? describe : describe.skip;
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
describeFn("JobEventsService Performance", () => {
let service: JobEventsService;

View File

@@ -27,7 +27,9 @@ async function isFulltextSearchConfigured(prisma: PrismaClient): Promise<boolean
* Skip when DATABASE_URL is not set. Tests that require the trigger/index
* will be skipped if the database migration hasn't been applied.
*/
const describeFn = process.env.DATABASE_URL ? describe : describe.skip;
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
describeFn("Full-Text Search Setup (Integration)", () => {
let prisma: PrismaClient;

View File

@@ -1,5 +1,5 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { RequestMethod, ValidationPipe } from "@nestjs/common";
import cookieParser from "cookie-parser";
import { AppModule } from "./app.module";
import { getTrustedOrigins } from "./auth/auth.config";
@@ -47,6 +47,16 @@ async function bootstrap() {
app.useGlobalFilters(new GlobalExceptionFilter());
// Set global API prefix — all routes get /api/* except auth and health
// Auth routes are excluded because BetterAuth expects /auth/* paths
// Health is excluded because Docker healthchecks hit /health directly
app.setGlobalPrefix("api", {
exclude: [
{ path: "health", method: RequestMethod.GET },
{ path: "auth/(.*)", method: RequestMethod.ALL },
],
});
// Configure CORS for cookie-based authentication
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
const trustedOrigins = getTrustedOrigins();

View File

@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { ConfigModule } from "@nestjs/config";
import { MosaicTelemetryModule } from "./mosaic-telemetry.module";
import { MosaicTelemetryService } from "./mosaic-telemetry.service";
import { PrismaService } from "../prisma/prisma.service";
// Mock the telemetry client to avoid real HTTP calls
vi.mock("@mosaicstack/telemetry-client", async (importOriginal) => {
@@ -56,6 +57,30 @@ vi.mock("@mosaicstack/telemetry-client", async (importOriginal) => {
describe("MosaicTelemetryModule", () => {
let module: TestingModule;
const sharedTestEnv = {
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
};
const mockPrismaService = {
onModuleInit: vi.fn(),
onModuleDestroy: vi.fn(),
$connect: vi.fn(),
$disconnect: vi.fn(),
};
const buildTestModule = async (env: Record<string, string>): Promise<TestingModule> =>
Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [],
load: [() => ({ ...env, ...sharedTestEnv })],
}),
MosaicTelemetryModule,
],
})
.overrideProvider(PrismaService)
.useValue(mockPrismaService)
.compile();
beforeEach(() => {
vi.clearAllMocks();
@@ -63,40 +88,18 @@ describe("MosaicTelemetryModule", () => {
describe("module initialization", () => {
it("should compile the module successfully", async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [],
load: [
() => ({
MOSAIC_TELEMETRY_ENABLED: "false",
}),
],
}),
MosaicTelemetryModule,
],
}).compile();
module = await buildTestModule({
MOSAIC_TELEMETRY_ENABLED: "false",
});
expect(module).toBeDefined();
await module.close();
});
it("should provide MosaicTelemetryService", async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [],
load: [
() => ({
MOSAIC_TELEMETRY_ENABLED: "false",
}),
],
}),
MosaicTelemetryModule,
],
}).compile();
module = await buildTestModule({
MOSAIC_TELEMETRY_ENABLED: "false",
});
const service = module.get<MosaicTelemetryService>(MosaicTelemetryService);
expect(service).toBeDefined();
@@ -106,20 +109,9 @@ describe("MosaicTelemetryModule", () => {
});
it("should export MosaicTelemetryService for injection in other modules", async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [],
load: [
() => ({
MOSAIC_TELEMETRY_ENABLED: "false",
}),
],
}),
MosaicTelemetryModule,
],
}).compile();
module = await buildTestModule({
MOSAIC_TELEMETRY_ENABLED: "false",
});
const service = module.get(MosaicTelemetryService);
expect(service).toBeDefined();
@@ -130,24 +122,13 @@ describe("MosaicTelemetryModule", () => {
describe("lifecycle integration", () => {
it("should initialize service on module init when enabled", async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [],
load: [
() => ({
MOSAIC_TELEMETRY_ENABLED: "true",
MOSAIC_TELEMETRY_SERVER_URL: "https://tel.test.local",
MOSAIC_TELEMETRY_API_KEY: "a".repeat(64),
MOSAIC_TELEMETRY_INSTANCE_ID: "550e8400-e29b-41d4-a716-446655440000",
MOSAIC_TELEMETRY_DRY_RUN: "false",
}),
],
}),
MosaicTelemetryModule,
],
}).compile();
module = await buildTestModule({
MOSAIC_TELEMETRY_ENABLED: "true",
MOSAIC_TELEMETRY_SERVER_URL: "https://tel.test.local",
MOSAIC_TELEMETRY_API_KEY: "a".repeat(64),
MOSAIC_TELEMETRY_INSTANCE_ID: "550e8400-e29b-41d4-a716-446655440000",
MOSAIC_TELEMETRY_DRY_RUN: "false",
});
await module.init();
@@ -158,20 +139,9 @@ describe("MosaicTelemetryModule", () => {
});
it("should not start client when disabled via env", async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [],
load: [
() => ({
MOSAIC_TELEMETRY_ENABLED: "false",
}),
],
}),
MosaicTelemetryModule,
],
}).compile();
module = await buildTestModule({
MOSAIC_TELEMETRY_ENABLED: "false",
});
await module.init();
@@ -182,24 +152,13 @@ describe("MosaicTelemetryModule", () => {
});
it("should cleanly shut down on module destroy", async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [],
load: [
() => ({
MOSAIC_TELEMETRY_ENABLED: "true",
MOSAIC_TELEMETRY_SERVER_URL: "https://tel.test.local",
MOSAIC_TELEMETRY_API_KEY: "a".repeat(64),
MOSAIC_TELEMETRY_INSTANCE_ID: "550e8400-e29b-41d4-a716-446655440000",
MOSAIC_TELEMETRY_DRY_RUN: "false",
}),
],
}),
MosaicTelemetryModule,
],
}).compile();
module = await buildTestModule({
MOSAIC_TELEMETRY_ENABLED: "true",
MOSAIC_TELEMETRY_SERVER_URL: "https://tel.test.local",
MOSAIC_TELEMETRY_API_KEY: "a".repeat(64),
MOSAIC_TELEMETRY_INSTANCE_ID: "550e8400-e29b-41d4-a716-446655440000",
MOSAIC_TELEMETRY_DRY_RUN: "false",
});
await module.init();

View File

@@ -156,7 +156,7 @@ describe("PrismaService", () => {
it("should set workspace context variables in transaction", async () => {
const userId = "user-123";
const workspaceId = "workspace-456";
const executeRawSpy = vi.spyOn(service, "$executeRaw").mockResolvedValue(0);
vi.spyOn(service, "$executeRaw").mockResolvedValue(0);
// Mock $transaction to execute the callback with a mock tx client
const mockTx = {
@@ -195,7 +195,6 @@ describe("PrismaService", () => {
};
// Mock both methods at the same time to avoid spy issues
const originalSetContext = service.setWorkspaceContext.bind(service);
const setContextCalls: [string, string, unknown][] = [];
service.setWorkspaceContext = vi.fn().mockImplementation((uid, wid, tx) => {
setContextCalls.push([uid, wid, tx]);

View File

@@ -3,6 +3,7 @@ import { PrismaClient } from "@prisma/client";
import { VaultService } from "../vault/vault.service";
import { createAccountEncryptionExtension } from "./account-encryption.extension";
import { createLlmEncryptionExtension } from "./llm-encryption.extension";
import { getRlsClient } from "./rls-context.provider";
/**
* Prisma service that manages database connection lifecycle
@@ -177,6 +178,13 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
workspaceId: string,
fn: (tx: PrismaClient) => Promise<T>
): Promise<T> {
const rlsClient = getRlsClient();
if (rlsClient) {
await this.setWorkspaceContext(userId, workspaceId, rlsClient as unknown as PrismaClient);
return fn(rlsClient as unknown as PrismaClient);
}
return this.$transaction(async (tx) => {
await this.setWorkspaceContext(userId, workspaceId, tx as PrismaClient);
return fn(tx as PrismaClient);

View File

@@ -4,6 +4,7 @@ import { RunnerJobsService } from "./runner-jobs.service";
import { PrismaModule } from "../prisma/prisma.module";
import { BullMqModule } from "../bullmq/bullmq.module";
import { AuthModule } from "../auth/auth.module";
import { WebSocketModule } from "../websocket/websocket.module";
/**
* Runner Jobs Module
@@ -12,7 +13,7 @@ import { AuthModule } from "../auth/auth.module";
* for asynchronous job processing.
*/
@Module({
imports: [PrismaModule, BullMqModule, AuthModule],
imports: [PrismaModule, BullMqModule, AuthModule, WebSocketModule],
controllers: [RunnerJobsController],
providers: [RunnerJobsService],
exports: [RunnerJobsService],

View File

@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { RunnerJobsService } from "./runner-jobs.service";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { WebSocketGateway } from "../websocket/websocket.gateway";
import { RunnerJobStatus } from "@prisma/client";
import { ConflictException, BadRequestException } from "@nestjs/common";
@@ -19,6 +20,12 @@ describe("RunnerJobsService - Concurrency", () => {
getQueue: vi.fn(),
};
const mockWebSocketGateway = {
emitJobCreated: vi.fn(),
emitJobStatusChanged: vi.fn(),
emitJobProgress: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -37,6 +44,10 @@ describe("RunnerJobsService - Concurrency", () => {
provide: BullMqService,
useValue: mockBullMqService,
},
{
provide: WebSocketGateway,
useValue: mockWebSocketGateway,
},
],
}).compile();

View File

@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { RunnerJobsService } from "./runner-jobs.service";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { WebSocketGateway } from "../websocket/websocket.gateway";
import { RunnerJobStatus } from "@prisma/client";
import { NotFoundException, BadRequestException } from "@nestjs/common";
import { CreateJobDto, QueryJobsDto } from "./dto";
@@ -32,6 +33,12 @@ describe("RunnerJobsService", () => {
getQueue: vi.fn(),
};
const mockWebSocketGateway = {
emitJobCreated: vi.fn(),
emitJobStatusChanged: vi.fn(),
emitJobProgress: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -44,6 +51,10 @@ describe("RunnerJobsService", () => {
provide: BullMqService,
useValue: mockBullMqService,
},
{
provide: WebSocketGateway,
useValue: mockWebSocketGateway,
},
],
}).compile();

View File

@@ -3,6 +3,7 @@ import { Prisma, RunnerJobStatus } from "@prisma/client";
import { Response } from "express";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { WebSocketGateway } from "../websocket/websocket.gateway";
import { QUEUE_NAMES } from "../bullmq/queues";
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
import type { CreateJobDto, QueryJobsDto } from "./dto";
@@ -14,7 +15,8 @@ import type { CreateJobDto, QueryJobsDto } from "./dto";
export class RunnerJobsService {
constructor(
private readonly prisma: PrismaService,
private readonly bullMq: BullMqService
private readonly bullMq: BullMqService,
private readonly wsGateway: WebSocketGateway
) {}
/**
@@ -56,6 +58,8 @@ export class RunnerJobsService {
{ priority }
);
this.wsGateway.emitJobCreated(workspaceId, job);
return job;
}
@@ -194,6 +198,13 @@ export class RunnerJobsService {
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
}
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
id,
workspaceId,
status: job.status,
previousStatus: existingJob.status,
});
return job;
});
}
@@ -248,6 +259,8 @@ export class RunnerJobsService {
{ priority: existingJob.priority }
);
this.wsGateway.emitJobCreated(workspaceId, newJob);
return newJob;
}
@@ -530,6 +543,13 @@ export class RunnerJobsService {
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
}
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
id,
workspaceId,
status: updatedJob.status,
previousStatus: existingJob.status,
});
return updatedJob;
});
}
@@ -606,6 +626,12 @@ export class RunnerJobsService {
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
}
this.wsGateway.emitJobProgress(workspaceId, id, {
id,
workspaceId,
progressPercent: updatedJob.progressPercent,
});
return updatedJob;
});
}

View File

@@ -25,6 +25,8 @@ describe("TasksController", () => {
const request = context.switchToHttp().getRequest();
request.user = {
id: "550e8400-e29b-41d4-a716-446655440002",
email: "test@example.com",
name: "Test User",
workspaceId: "550e8400-e29b-41d4-a716-446655440001",
};
return true;
@@ -46,6 +48,8 @@ describe("TasksController", () => {
const mockRequest = {
user: {
id: mockUserId,
email: "test@example.com",
name: "Test User",
workspaceId: mockWorkspaceId,
},
};
@@ -132,13 +136,16 @@ describe("TasksController", () => {
mockTasksService.findAll.mockResolvedValue(paginatedResult);
const result = await controller.findAll(query, mockWorkspaceId);
const result = await controller.findAll(query, mockWorkspaceId, mockRequest.user);
expect(result).toEqual(paginatedResult);
expect(service.findAll).toHaveBeenCalledWith({
...query,
workspaceId: mockWorkspaceId,
});
expect(service.findAll).toHaveBeenCalledWith(
{
...query,
workspaceId: mockWorkspaceId,
},
mockUserId
);
});
it("should extract workspaceId from request.user if not in query", async () => {
@@ -149,12 +156,13 @@ describe("TasksController", () => {
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
});
await controller.findAll(query as any, mockWorkspaceId);
await controller.findAll(query as any, mockWorkspaceId, mockRequest.user);
expect(service.findAll).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: mockWorkspaceId,
})
}),
mockUserId
);
});
});
@@ -163,10 +171,10 @@ describe("TasksController", () => {
it("should return a task by id", async () => {
mockTasksService.findOne.mockResolvedValue(mockTask);
const result = await controller.findOne(mockTaskId, mockWorkspaceId);
const result = await controller.findOne(mockTaskId, mockWorkspaceId, mockRequest.user);
expect(result).toEqual(mockTask);
expect(service.findOne).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId);
expect(service.findOne).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId, mockUserId);
});
it("should throw error if workspaceId not found", async () => {
@@ -175,10 +183,10 @@ describe("TasksController", () => {
// We can test that the controller properly uses the provided workspaceId instead
mockTasksService.findOne.mockResolvedValue(mockTask);
const result = await controller.findOne(mockTaskId, mockWorkspaceId);
const result = await controller.findOne(mockTaskId, mockWorkspaceId, mockRequest.user);
expect(result).toEqual(mockTask);
expect(service.findOne).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId);
expect(service.findOne).toHaveBeenCalledWith(mockTaskId, mockWorkspaceId, mockUserId);
});
});

View File

@@ -53,8 +53,12 @@ export class TasksController {
*/
@Get()
@RequirePermission(Permission.WORKSPACE_ANY)
async findAll(@Query() query: QueryTasksDto, @Workspace() workspaceId: string) {
return this.tasksService.findAll(Object.assign({}, query, { workspaceId }));
async findAll(
@Query() query: QueryTasksDto,
@Workspace() workspaceId: string,
@CurrentUser() user: AuthenticatedUser
) {
return this.tasksService.findAll(Object.assign({}, query, { workspaceId }), user.id);
}
/**
@@ -64,8 +68,12 @@ export class TasksController {
*/
@Get(":id")
@RequirePermission(Permission.WORKSPACE_ANY)
async findOne(@Param("id") id: string, @Workspace() workspaceId: string) {
return this.tasksService.findOne(id, workspaceId);
async findOne(
@Param("id") id: string,
@Workspace() workspaceId: string,
@CurrentUser() user: AuthenticatedUser
) {
return this.tasksService.findOne(id, workspaceId, user.id);
}
/**

View File

@@ -21,6 +21,7 @@ describe("TasksService", () => {
update: vi.fn(),
delete: vi.fn(),
},
withWorkspaceContext: vi.fn(),
};
const mockActivityService = {
@@ -75,6 +76,9 @@ describe("TasksService", () => {
// Clear all mocks before each test
vi.clearAllMocks();
mockPrismaService.withWorkspaceContext.mockImplementation(async (_userId, _workspaceId, fn) => {
return fn(mockPrismaService as unknown as PrismaService);
});
});
it("should be defined", () => {
@@ -95,6 +99,11 @@ describe("TasksService", () => {
const result = await service.create(mockWorkspaceId, mockUserId, createDto);
expect(result).toEqual(mockTask);
expect(prisma.withWorkspaceContext).toHaveBeenCalledWith(
mockUserId,
mockWorkspaceId,
expect.any(Function)
);
expect(prisma.task.create).toHaveBeenCalledWith({
data: {
title: createDto.title,
@@ -177,6 +186,29 @@ describe("TasksService", () => {
});
});
it("should use workspace context when userId is provided", async () => {
mockPrismaService.task.findMany.mockResolvedValue([mockTask]);
mockPrismaService.task.count.mockResolvedValue(1);
await service.findAll({ workspaceId: mockWorkspaceId }, mockUserId);
expect(prisma.withWorkspaceContext).toHaveBeenCalledWith(
mockUserId,
mockWorkspaceId,
expect.any(Function)
);
});
it("should fallback to direct Prisma access when userId is missing", async () => {
mockPrismaService.task.findMany.mockResolvedValue([mockTask]);
mockPrismaService.task.count.mockResolvedValue(1);
await service.findAll({ workspaceId: mockWorkspaceId });
expect(prisma.withWorkspaceContext).not.toHaveBeenCalled();
expect(prisma.task.findMany).toHaveBeenCalled();
});
it("should filter by status", async () => {
mockPrismaService.task.findMany.mockResolvedValue([mockTask]);
mockPrismaService.task.count.mockResolvedValue(1);

View File

@@ -1,8 +1,7 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, Task } from "@prisma/client";
import { Prisma, Task, TaskStatus, TaskPriority, type PrismaClient } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityService } from "../activity/activity.service";
import { TaskStatus, TaskPriority } from "@prisma/client";
import type { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from "./dto";
type TaskWithRelations = Task & {
@@ -24,6 +23,18 @@ export class TasksService {
private readonly activityService: ActivityService
) {}
private async withWorkspaceContextIfAvailable<T>(
workspaceId: string | undefined,
userId: string | undefined,
fn: (client: PrismaClient) => Promise<T>
): Promise<T> {
if (workspaceId && userId && typeof this.prisma.withWorkspaceContext === "function") {
return this.prisma.withWorkspaceContext(userId, workspaceId, fn);
}
return fn(this.prisma);
}
/**
* Create a new task
*/
@@ -66,19 +77,21 @@ export class TasksService {
data.completedAt = new Date();
}
const task = await this.prisma.task.create({
data,
include: {
assignee: {
select: { id: true, name: true, email: true },
const task = await this.withWorkspaceContextIfAvailable(workspaceId, userId, async (client) => {
return client.task.create({
data,
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
});
});
// Log activity
@@ -92,7 +105,10 @@ export class TasksService {
/**
* Get paginated tasks with filters
*/
async findAll(query: QueryTasksDto): Promise<{
async findAll(
query: QueryTasksDto,
userId?: string
): Promise<{
data: Omit<TaskWithRelations, "subtasks">[];
meta: {
total: number;
@@ -143,28 +159,34 @@ export class TasksService {
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.task.findMany({
where,
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
this.prisma.task.count({ where }),
]);
const [data, total] = await this.withWorkspaceContextIfAvailable(
query.workspaceId,
userId,
async (client) => {
return Promise.all([
client.task.findMany({
where,
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
client.task.count({ where }),
]);
}
);
return {
data,
@@ -180,30 +202,32 @@ export class TasksService {
/**
* Get a single task by ID
*/
async findOne(id: string, workspaceId: string): Promise<TaskWithRelations> {
const task = await this.prisma.task.findUnique({
where: {
id,
workspaceId,
},
include: {
assignee: {
select: { id: true, name: true, email: true },
async findOne(id: string, workspaceId: string, userId?: string): Promise<TaskWithRelations> {
const task = await this.withWorkspaceContextIfAvailable(workspaceId, userId, async (client) => {
return client.task.findUnique({
where: {
id,
workspaceId,
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
subtasks: {
include: {
assignee: {
select: { id: true, name: true, email: true },
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
subtasks: {
include: {
assignee: {
select: { id: true, name: true, email: true },
},
},
},
},
},
});
});
if (!task) {
@@ -222,82 +246,89 @@ export class TasksService {
userId: string,
updateTaskDto: UpdateTaskDto
): Promise<Omit<TaskWithRelations, "subtasks">> {
// Verify task exists
const existingTask = await this.prisma.task.findUnique({
where: { id, workspaceId },
});
const { task, existingTask } = await this.withWorkspaceContextIfAvailable(
workspaceId,
userId,
async (client) => {
const existingTask = await client.task.findUnique({
where: { id, workspaceId },
});
if (!existingTask) {
throw new NotFoundException(`Task with ID ${id} not found`);
}
if (!existingTask) {
throw new NotFoundException(`Task with ID ${id} not found`);
}
// Build update data - only include defined fields
const data: Prisma.TaskUpdateInput = {};
// Build update data - only include defined fields
const data: Prisma.TaskUpdateInput = {};
if (updateTaskDto.title !== undefined) {
data.title = updateTaskDto.title;
}
if (updateTaskDto.description !== undefined) {
data.description = updateTaskDto.description;
}
if (updateTaskDto.status !== undefined) {
data.status = updateTaskDto.status;
}
if (updateTaskDto.priority !== undefined) {
data.priority = updateTaskDto.priority;
}
if (updateTaskDto.dueDate !== undefined) {
data.dueDate = updateTaskDto.dueDate;
}
if (updateTaskDto.sortOrder !== undefined) {
data.sortOrder = updateTaskDto.sortOrder;
}
if (updateTaskDto.metadata !== undefined) {
data.metadata = updateTaskDto.metadata as unknown as Prisma.InputJsonValue;
}
if (updateTaskDto.assigneeId !== undefined && updateTaskDto.assigneeId !== null) {
data.assignee = { connect: { id: updateTaskDto.assigneeId } };
}
if (updateTaskDto.projectId !== undefined && updateTaskDto.projectId !== null) {
data.project = { connect: { id: updateTaskDto.projectId } };
}
if (updateTaskDto.parentId !== undefined && updateTaskDto.parentId !== null) {
data.parent = { connect: { id: updateTaskDto.parentId } };
}
if (updateTaskDto.title !== undefined) {
data.title = updateTaskDto.title;
}
if (updateTaskDto.description !== undefined) {
data.description = updateTaskDto.description;
}
if (updateTaskDto.status !== undefined) {
data.status = updateTaskDto.status;
}
if (updateTaskDto.priority !== undefined) {
data.priority = updateTaskDto.priority;
}
if (updateTaskDto.dueDate !== undefined) {
data.dueDate = updateTaskDto.dueDate;
}
if (updateTaskDto.sortOrder !== undefined) {
data.sortOrder = updateTaskDto.sortOrder;
}
if (updateTaskDto.metadata !== undefined) {
data.metadata = updateTaskDto.metadata as unknown as Prisma.InputJsonValue;
}
if (updateTaskDto.assigneeId !== undefined && updateTaskDto.assigneeId !== null) {
data.assignee = { connect: { id: updateTaskDto.assigneeId } };
}
if (updateTaskDto.projectId !== undefined && updateTaskDto.projectId !== null) {
data.project = { connect: { id: updateTaskDto.projectId } };
}
if (updateTaskDto.parentId !== undefined && updateTaskDto.parentId !== null) {
data.parent = { connect: { id: updateTaskDto.parentId } };
}
// Handle completedAt based on status changes
if (updateTaskDto.status) {
if (
updateTaskDto.status === TaskStatus.COMPLETED &&
existingTask.status !== TaskStatus.COMPLETED
) {
data.completedAt = new Date();
} else if (
updateTaskDto.status !== TaskStatus.COMPLETED &&
existingTask.status === TaskStatus.COMPLETED
) {
data.completedAt = null;
// Handle completedAt based on status changes
if (updateTaskDto.status) {
if (
updateTaskDto.status === TaskStatus.COMPLETED &&
existingTask.status !== TaskStatus.COMPLETED
) {
data.completedAt = new Date();
} else if (
updateTaskDto.status !== TaskStatus.COMPLETED &&
existingTask.status === TaskStatus.COMPLETED
) {
data.completedAt = null;
}
}
const task = await client.task.update({
where: {
id,
workspaceId,
},
data,
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
});
return { task, existingTask };
}
}
const task = await this.prisma.task.update({
where: {
id,
workspaceId,
},
data,
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
});
);
// Log activities
await this.activityService.logTaskUpdated(workspaceId, userId, id, {
@@ -332,20 +363,23 @@ export class TasksService {
* Delete a task
*/
async remove(id: string, workspaceId: string, userId: string): Promise<void> {
// Verify task exists
const task = await this.prisma.task.findUnique({
where: { id, workspaceId },
});
const task = await this.withWorkspaceContextIfAvailable(workspaceId, userId, async (client) => {
const task = await client.task.findUnique({
where: { id, workspaceId },
});
if (!task) {
throw new NotFoundException(`Task with ID ${id} not found`);
}
if (!task) {
throw new NotFoundException(`Task with ID ${id} not found`);
}
await this.prisma.task.delete({
where: {
id,
workspaceId,
},
await client.task.delete({
where: {
id,
workspaceId,
},
});
return task;
});
// Log activity

View File

@@ -50,6 +50,8 @@ describe("TelemetryInterceptor", () => {
getResponse: vi.fn().mockReturnValue({
statusCode: 200,
setHeader: vi.fn(),
headersSent: false,
writableEnded: false,
}),
}),
getClass: vi.fn().mockReturnValue({ name: "TestController" }),
@@ -101,6 +103,35 @@ describe("TelemetryInterceptor", () => {
expect(mockResponse.setHeader).toHaveBeenCalledWith("x-trace-id", "test-trace-id");
});
it("should not set trace header when response is already committed", async () => {
const committedResponseContext = {
...mockContext,
switchToHttp: vi.fn().mockReturnValue({
getRequest: vi.fn().mockReturnValue({
method: "GET",
url: "/api/test",
path: "/api/test",
}),
getResponse: vi.fn().mockReturnValue({
statusCode: 200,
setHeader: vi.fn(),
headersSent: true,
writableEnded: true,
}),
}),
} as unknown as ExecutionContext;
mockHandler = {
handle: vi.fn().mockReturnValue(of({ data: "test" })),
} as unknown as CallHandler;
const committedResponse = committedResponseContext.switchToHttp().getResponse();
await lastValueFrom(interceptor.intercept(committedResponseContext, mockHandler));
expect(committedResponse.setHeader).not.toHaveBeenCalled();
});
it("should record exception on error", async () => {
const error = new Error("Test error");
mockHandler = {

View File

@@ -88,7 +88,7 @@ export class TelemetryInterceptor implements NestInterceptor {
// Add trace context to response headers for distributed tracing
const spanContext = span.spanContext();
if (spanContext.traceId) {
if (spanContext.traceId && !response.headersSent && !response.writableEnded) {
response.setHeader("x-trace-id", spanContext.traceId);
}
} catch (error) {

View File

@@ -0,0 +1,53 @@
/**
* Terminal Session DTOs
*
* Data Transfer Objects for terminal session persistence endpoints.
* Validated using class-validator decorators.
*/
import { IsString, IsOptional, MaxLength, IsEnum, IsUUID } from "class-validator";
import { TerminalSessionStatus } from "@prisma/client";
/**
* DTO for creating a new terminal session record.
*/
export class CreateTerminalSessionDto {
@IsString()
@IsUUID()
workspaceId!: string;
@IsOptional()
@IsString()
@MaxLength(128)
name?: string;
}
/**
* DTO for querying terminal sessions by workspace.
*/
export class FindTerminalSessionsByWorkspaceDto {
@IsString()
@IsUUID()
workspaceId!: string;
}
/**
* Response shape for a terminal session.
*/
export class TerminalSessionResponseDto {
id!: string;
workspaceId!: string;
name!: string;
status!: TerminalSessionStatus;
createdAt!: Date;
closedAt!: Date | null;
}
/**
* DTO for filtering terminal sessions by status.
*/
export class TerminalSessionStatusFilterDto {
@IsOptional()
@IsEnum(TerminalSessionStatus)
status?: TerminalSessionStatus;
}

View File

@@ -0,0 +1,229 @@
/**
* TerminalSessionService Tests
*
* Unit tests for database-backed terminal session CRUD:
* create, findByWorkspace, close, and findById.
* PrismaService is mocked to isolate the service logic.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NotFoundException } from "@nestjs/common";
import { TerminalSessionStatus } from "@prisma/client";
import type { TerminalSession } from "@prisma/client";
import { TerminalSessionService } from "./terminal-session.service";
// ==========================================
// Helpers
// ==========================================
function makeSession(overrides: Partial<TerminalSession> = {}): TerminalSession {
return {
id: "session-uuid-1",
workspaceId: "workspace-uuid-1",
name: "Terminal",
status: TerminalSessionStatus.ACTIVE,
createdAt: new Date("2026-02-25T00:00:00Z"),
closedAt: null,
...overrides,
};
}
// ==========================================
// Mock PrismaService
// ==========================================
function makeMockPrisma() {
return {
terminalSession: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
};
}
// ==========================================
// Tests
// ==========================================
describe("TerminalSessionService", () => {
let service: TerminalSessionService;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockPrisma: any;
beforeEach(() => {
vi.clearAllMocks();
mockPrisma = makeMockPrisma();
service = new TerminalSessionService(mockPrisma);
});
// ==========================================
// create
// ==========================================
describe("create", () => {
it("should call prisma.terminalSession.create with workspaceId only when no name provided", async () => {
const session = makeSession();
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
const result = await service.create("workspace-uuid-1");
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
data: { workspaceId: "workspace-uuid-1" },
});
expect(result).toEqual(session);
});
it("should include name in create data when name is provided", async () => {
const session = makeSession({ name: "My Terminal" });
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
const result = await service.create("workspace-uuid-1", "My Terminal");
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
data: { workspaceId: "workspace-uuid-1", name: "My Terminal" },
});
expect(result).toEqual(session);
});
it("should return the created session", async () => {
const session = makeSession();
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
const result = await service.create("workspace-uuid-1");
expect(result.id).toBe("session-uuid-1");
expect(result.status).toBe(TerminalSessionStatus.ACTIVE);
});
});
// ==========================================
// findByWorkspace
// ==========================================
describe("findByWorkspace", () => {
it("should query for ACTIVE sessions in the given workspace, ordered by createdAt desc", async () => {
const sessions = [makeSession(), makeSession({ id: "session-uuid-2" })];
mockPrisma.terminalSession.findMany.mockResolvedValueOnce(sessions);
const result = await service.findByWorkspace("workspace-uuid-1");
expect(mockPrisma.terminalSession.findMany).toHaveBeenCalledWith({
where: {
workspaceId: "workspace-uuid-1",
status: TerminalSessionStatus.ACTIVE,
},
orderBy: { createdAt: "desc" },
});
expect(result).toHaveLength(2);
});
it("should return an empty array when no active sessions exist", async () => {
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
const result = await service.findByWorkspace("workspace-uuid-empty");
expect(result).toEqual([]);
});
it("should not include CLOSED sessions", async () => {
// The where clause enforces ACTIVE status — verify it is present
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
await service.findByWorkspace("workspace-uuid-1");
const callArgs = mockPrisma.terminalSession.findMany.mock.calls[0][0] as {
where: { status: TerminalSessionStatus };
};
expect(callArgs.where.status).toBe(TerminalSessionStatus.ACTIVE);
});
});
// ==========================================
// close
// ==========================================
describe("close", () => {
it("should set status to CLOSED and set closedAt when session exists", async () => {
const existingSession = makeSession();
const closedSession = makeSession({
status: TerminalSessionStatus.CLOSED,
closedAt: new Date("2026-02-25T01:00:00Z"),
});
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
const result = await service.close("session-uuid-1");
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
where: { id: "session-uuid-1" },
});
expect(mockPrisma.terminalSession.update).toHaveBeenCalledWith({
where: { id: "session-uuid-1" },
data: {
status: TerminalSessionStatus.CLOSED,
closedAt: expect.any(Date),
},
});
expect(result.status).toBe(TerminalSessionStatus.CLOSED);
});
it("should throw NotFoundException when session does not exist", async () => {
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
await expect(service.close("nonexistent-id")).rejects.toThrow(NotFoundException);
expect(mockPrisma.terminalSession.update).not.toHaveBeenCalled();
});
it("should include a non-null closedAt timestamp on close", async () => {
const existingSession = makeSession();
const closedSession = makeSession({
status: TerminalSessionStatus.CLOSED,
closedAt: new Date(),
});
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
const result = await service.close("session-uuid-1");
expect(result.closedAt).not.toBeNull();
});
});
// ==========================================
// findById
// ==========================================
describe("findById", () => {
it("should return the session when it exists", async () => {
const session = makeSession();
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(session);
const result = await service.findById("session-uuid-1");
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
where: { id: "session-uuid-1" },
});
expect(result).toEqual(session);
});
it("should return null when session does not exist", async () => {
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
const result = await service.findById("no-such-id");
expect(result).toBeNull();
});
it("should find CLOSED sessions as well as ACTIVE ones", async () => {
const closedSession = makeSession({
status: TerminalSessionStatus.CLOSED,
closedAt: new Date(),
});
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(closedSession);
const result = await service.findById("session-uuid-1");
expect(result?.status).toBe(TerminalSessionStatus.CLOSED);
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* TerminalSessionService
*
* Manages database persistence for terminal sessions.
* Provides CRUD operations on the TerminalSession model,
* enabling session tracking, recovery, and workspace-level listing.
*
* Session lifecycle:
* - create: record a new terminal session with ACTIVE status
* - findByWorkspace: return all ACTIVE sessions for a workspace
* - close: mark a session as CLOSED, set closedAt timestamp
* - findById: retrieve a single session by ID
*/
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import { TerminalSessionStatus } from "@prisma/client";
import type { TerminalSession } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class TerminalSessionService {
private readonly logger = new Logger(TerminalSessionService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new terminal session record in the database.
*
* @param workspaceId - The workspace this session belongs to
* @param name - Optional display name for the session (defaults to "Terminal")
* @returns The created TerminalSession record
*/
async create(workspaceId: string, name?: string): Promise<TerminalSession> {
this.logger.log(
`Creating terminal session for workspace ${workspaceId}${name !== undefined ? ` (name: ${name})` : ""}`
);
const data: { workspaceId: string; name?: string } = { workspaceId };
if (name !== undefined) {
data.name = name;
}
return this.prisma.terminalSession.create({ data });
}
/**
* Find all ACTIVE terminal sessions for a workspace.
*
* @param workspaceId - The workspace to query
* @returns Array of active TerminalSession records, ordered by creation time (newest first)
*/
async findByWorkspace(workspaceId: string): Promise<TerminalSession[]> {
return this.prisma.terminalSession.findMany({
where: {
workspaceId,
status: TerminalSessionStatus.ACTIVE,
},
orderBy: { createdAt: "desc" },
});
}
/**
* Close a terminal session by setting its status to CLOSED and recording closedAt.
*
* @param id - The session ID to close
* @returns The updated TerminalSession record
* @throws NotFoundException if the session does not exist
*/
async close(id: string): Promise<TerminalSession> {
const existing = await this.prisma.terminalSession.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`Terminal session ${id} not found`);
}
this.logger.log(`Closing terminal session ${id} (workspace: ${existing.workspaceId})`);
return this.prisma.terminalSession.update({
where: { id },
data: {
status: TerminalSessionStatus.CLOSED,
closedAt: new Date(),
},
});
}
/**
* Find a terminal session by ID.
*
* @param id - The session ID to retrieve
* @returns The TerminalSession record, or null if not found
*/
async findById(id: string): Promise<TerminalSession | null> {
return this.prisma.terminalSession.findUnique({ where: { id } });
}
}

View File

@@ -0,0 +1,89 @@
/**
* Terminal DTOs
*
* Data Transfer Objects for terminal WebSocket events.
* Validated using class-validator decorators.
*/
import {
IsString,
IsOptional,
IsNumber,
IsInt,
Min,
Max,
MinLength,
MaxLength,
} from "class-validator";
/**
* DTO for creating a new terminal PTY session.
*/
export class CreateTerminalDto {
@IsOptional()
@IsString()
@MaxLength(128)
name?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(500)
cols?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(200)
rows?: number;
@IsOptional()
@IsString()
@MaxLength(4096)
cwd?: string;
}
/**
* DTO for sending input data to a terminal PTY session.
*/
export class TerminalInputDto {
@IsString()
@MinLength(1)
@MaxLength(64)
sessionId!: string;
@IsString()
data!: string;
}
/**
* DTO for resizing a terminal PTY session.
*/
export class TerminalResizeDto {
@IsString()
@MinLength(1)
@MaxLength(64)
sessionId!: string;
@IsNumber()
@IsInt()
@Min(1)
@Max(500)
cols!: number;
@IsNumber()
@IsInt()
@Min(1)
@Max(200)
rows!: number;
}
/**
* DTO for closing a terminal PTY session.
*/
export class CloseTerminalDto {
@IsString()
@MinLength(1)
@MaxLength(64)
sessionId!: string;
}

View File

@@ -0,0 +1,501 @@
/**
* TerminalGateway Tests
*
* Unit tests for WebSocket terminal gateway:
* - Authentication on connection
* - terminal:create event handling
* - terminal:input event handling
* - terminal:resize event handling
* - terminal:close event handling
* - disconnect cleanup
* - Error paths
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import type { Socket } from "socket.io";
import { TerminalGateway } from "./terminal.gateway";
import { TerminalService } from "./terminal.service";
import { AuthService } from "../auth/auth.service";
import { PrismaService } from "../prisma/prisma.service";
// ==========================================
// Mocks
// ==========================================
// Mock node-pty globally so TerminalService doesn't fail to import
vi.mock("node-pty", () => ({
spawn: vi.fn(() => ({
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
pid: 1000,
})),
}));
interface AuthenticatedSocket extends Socket {
data: {
userId?: string;
workspaceId?: string;
};
}
function createMockSocket(id = "test-socket-id"): AuthenticatedSocket {
return {
id,
emit: vi.fn(),
join: vi.fn(),
leave: vi.fn(),
disconnect: vi.fn(),
data: {},
handshake: {
auth: { token: "valid-token" },
query: {},
headers: {},
},
} as unknown as AuthenticatedSocket;
}
function createMockAuthService() {
return {
verifySession: vi.fn().mockResolvedValue({
user: { id: "user-123" },
session: { id: "session-123" },
}),
};
}
function createMockPrismaService() {
return {
workspaceMember: {
findFirst: vi.fn().mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
}),
},
};
}
function createMockTerminalService() {
return {
createSession: vi.fn().mockReturnValue({
sessionId: "session-uuid-1",
name: undefined,
cols: 80,
rows: 24,
}),
writeToSession: vi.fn(),
resizeSession: vi.fn(),
closeSession: vi.fn().mockReturnValue(true),
closeWorkspaceSessions: vi.fn(),
sessionBelongsToWorkspace: vi.fn().mockReturnValue(true),
getWorkspaceSessionCount: vi.fn().mockReturnValue(0),
};
}
// ==========================================
// Tests
// ==========================================
describe("TerminalGateway", () => {
let gateway: TerminalGateway;
let mockAuthService: ReturnType<typeof createMockAuthService>;
let mockPrismaService: ReturnType<typeof createMockPrismaService>;
let mockTerminalService: ReturnType<typeof createMockTerminalService>;
let mockClient: AuthenticatedSocket;
beforeEach(() => {
mockAuthService = createMockAuthService();
mockPrismaService = createMockPrismaService();
mockTerminalService = createMockTerminalService();
mockClient = createMockSocket();
gateway = new TerminalGateway(
mockAuthService as unknown as AuthService,
mockPrismaService as unknown as PrismaService,
mockTerminalService as unknown as TerminalService
);
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
// ==========================================
// handleConnection (authentication)
// ==========================================
describe("handleConnection", () => {
it("should authenticate client and join workspace room on valid token", async () => {
mockAuthService.verifySession.mockResolvedValue({
user: { id: "user-123" },
});
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
expect(mockClient.data.userId).toBe("user-123");
expect(mockClient.data.workspaceId).toBe("workspace-456");
expect(mockClient.join).toHaveBeenCalledWith("terminal:workspace-456");
});
it("should disconnect and emit error if no token provided", async () => {
const clientNoToken = createMockSocket("no-token");
clientNoToken.handshake = {
auth: {},
query: {},
headers: {},
} as typeof clientNoToken.handshake;
await gateway.handleConnection(clientNoToken);
expect(clientNoToken.disconnect).toHaveBeenCalled();
expect(clientNoToken.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("no token") })
);
});
it("should disconnect and emit error if token is invalid", async () => {
mockAuthService.verifySession.mockResolvedValue(null);
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).toHaveBeenCalled();
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("invalid") })
);
});
it("should disconnect and emit error if no workspace access", async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null);
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).toHaveBeenCalled();
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("workspace") })
);
});
it("should disconnect and emit error if auth throws", async () => {
mockAuthService.verifySession.mockRejectedValue(new Error("Auth service down"));
await gateway.handleConnection(mockClient);
expect(mockClient.disconnect).toHaveBeenCalled();
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.any(String) })
);
});
it("should extract token from handshake.query as fallback", async () => {
const clientQueryToken = createMockSocket("query-token-client");
clientQueryToken.handshake = {
auth: {},
query: { token: "query-token" },
headers: {},
} as typeof clientQueryToken.handshake;
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(clientQueryToken);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token");
});
it("should extract token from Authorization header as last fallback", async () => {
const clientHeaderToken = createMockSocket("header-token-client");
clientHeaderToken.handshake = {
auth: {},
query: {},
headers: { authorization: "Bearer header-token" },
} as typeof clientHeaderToken.handshake;
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(clientHeaderToken);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("header-token");
});
});
// ==========================================
// handleDisconnect
// ==========================================
describe("handleDisconnect", () => {
it("should close all workspace sessions on disconnect", async () => {
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
gateway.handleDisconnect(mockClient);
expect(mockTerminalService.closeWorkspaceSessions).toHaveBeenCalledWith("workspace-456");
});
it("should not throw for unauthenticated client disconnect", () => {
const unauthClient = createMockSocket("unauth-disconnect");
expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow();
expect(mockTerminalService.closeWorkspaceSessions).not.toHaveBeenCalled();
});
});
// ==========================================
// handleCreate (terminal:create)
// ==========================================
describe("handleCreate", () => {
beforeEach(async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
});
it("should create a PTY session and emit terminal:created", async () => {
mockTerminalService.createSession.mockReturnValue({
sessionId: "new-session-id",
cols: 80,
rows: 24,
});
await gateway.handleCreate(mockClient, {});
expect(mockTerminalService.createSession).toHaveBeenCalled();
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:created",
expect.objectContaining({ sessionId: "new-session-id" })
);
});
it("should pass cols, rows, cwd, name to service", async () => {
await gateway.handleCreate(mockClient, {
cols: 132,
rows: 50,
cwd: "/home/user",
name: "my-shell",
});
expect(mockTerminalService.createSession).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ cols: 132, rows: 50, cwd: "/home/user", name: "my-shell" })
);
});
it("should emit terminal:error if not authenticated", async () => {
const unauthClient = createMockSocket("unauth");
await gateway.handleCreate(unauthClient, {});
expect(unauthClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("authenticated") })
);
});
it("should emit terminal:error if service throws (session limit)", async () => {
mockTerminalService.createSession.mockImplementation(() => {
throw new Error("Workspace has reached the maximum of 10 concurrent terminal sessions");
});
await gateway.handleCreate(mockClient, {});
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("maximum") })
);
});
it("should emit terminal:error for invalid payload (negative cols)", async () => {
await gateway.handleCreate(mockClient, { cols: -1 });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
);
});
});
// ==========================================
// handleInput (terminal:input)
// ==========================================
describe("handleInput", () => {
beforeEach(async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
});
it("should write data to the PTY session", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
await gateway.handleInput(mockClient, { sessionId: "sess-1", data: "ls\n" });
expect(mockTerminalService.writeToSession).toHaveBeenCalledWith("sess-1", "ls\n");
});
it("should emit terminal:error if session does not belong to workspace", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
await gateway.handleInput(mockClient, { sessionId: "alien-sess", data: "data" });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("not found") })
);
expect(mockTerminalService.writeToSession).not.toHaveBeenCalled();
});
it("should emit terminal:error if not authenticated", async () => {
const unauthClient = createMockSocket("unauth");
await gateway.handleInput(unauthClient, { sessionId: "sess-1", data: "x" });
expect(unauthClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("authenticated") })
);
});
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
await gateway.handleInput(mockClient, { data: "some input" });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
);
});
});
// ==========================================
// handleResize (terminal:resize)
// ==========================================
describe("handleResize", () => {
beforeEach(async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
});
it("should resize the PTY session", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 120, rows: 40 });
expect(mockTerminalService.resizeSession).toHaveBeenCalledWith("sess-1", 120, 40);
});
it("should emit terminal:error if session does not belong to workspace", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
await gateway.handleResize(mockClient, { sessionId: "alien-sess", cols: 80, rows: 24 });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("not found") })
);
});
it("should emit terminal:error for invalid payload (cols too large)", async () => {
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 9999, rows: 24 });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
);
});
});
// ==========================================
// handleClose (terminal:close)
// ==========================================
describe("handleClose", () => {
beforeEach(async () => {
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
userId: "user-123",
workspaceId: "workspace-456",
role: "MEMBER",
});
await gateway.handleConnection(mockClient);
vi.clearAllMocks();
});
it("should close an existing PTY session", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
mockTerminalService.closeSession.mockReturnValue(true);
await gateway.handleClose(mockClient, { sessionId: "sess-1" });
expect(mockTerminalService.closeSession).toHaveBeenCalledWith("sess-1");
});
it("should emit terminal:error if session does not belong to workspace", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
await gateway.handleClose(mockClient, { sessionId: "alien-sess" });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("not found") })
);
});
it("should emit terminal:error if closeSession returns false (session gone)", async () => {
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
mockTerminalService.closeSession.mockReturnValue(false);
await gateway.handleClose(mockClient, { sessionId: "gone-sess" });
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("not found") })
);
});
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
await gateway.handleClose(mockClient, {});
expect(mockClient.emit).toHaveBeenCalledWith(
"terminal:error",
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
);
});
});
});

View File

@@ -0,0 +1,423 @@
/**
* TerminalGateway
*
* WebSocket gateway for real-time PTY terminal sessions.
* Uses the `/terminal` namespace to keep terminal traffic separate
* from the main WebSocket gateway.
*
* Protocol:
* 1. Client connects with auth token in handshake
* 2. Client emits `terminal:create` to spawn a new PTY session
* 3. Server emits `terminal:created` with { sessionId }
* 4. Client emits `terminal:input` with { sessionId, data } to send keystrokes
* 5. Server emits `terminal:output` with { sessionId, data } for stdout/stderr
* 6. Client emits `terminal:resize` with { sessionId, cols, rows } on window resize
* 7. Client emits `terminal:close` with { sessionId } to terminate the PTY
* 8. Server emits `terminal:exit` with { sessionId, exitCode, signal } on PTY exit
*
* Authentication:
* - Same pattern as websocket.gateway.ts and speech.gateway.ts
* - Token extracted from handshake.auth.token / query.token / Authorization header
*
* Workspace isolation:
* - Clients join room `terminal:{workspaceId}` on connect
* - Sessions are scoped to workspace; cross-workspace access is denied
*/
import {
WebSocketGateway as WSGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
} from "@nestjs/websockets";
import { Logger } from "@nestjs/common";
import { Server, Socket } from "socket.io";
import { AuthService } from "../auth/auth.service";
import { PrismaService } from "../prisma/prisma.service";
import { TerminalService } from "./terminal.service";
import {
CreateTerminalDto,
TerminalInputDto,
TerminalResizeDto,
CloseTerminalDto,
} from "./terminal.dto";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
// ==========================================
// Types
// ==========================================
interface AuthenticatedSocket extends Socket {
data: {
userId?: string;
workspaceId?: string;
};
}
// ==========================================
// Gateway
// ==========================================
@WSGateway({
namespace: "/terminal",
cors: {
origin: process.env.WEB_URL ?? "http://localhost:3000",
credentials: true,
},
})
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(TerminalGateway.name);
private readonly CONNECTION_TIMEOUT_MS = 5000;
constructor(
private readonly authService: AuthService,
private readonly prisma: PrismaService,
private readonly terminalService: TerminalService
) {}
// ==========================================
// Connection lifecycle
// ==========================================
/**
* Authenticate client on connection using handshake token.
* Validates workspace membership and joins the workspace-scoped room.
*/
async handleConnection(client: Socket): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const timeoutId = setTimeout(() => {
if (!authenticatedClient.data.userId) {
this.logger.warn(
`Terminal client ${authenticatedClient.id} timed out during authentication`
);
authenticatedClient.emit("terminal:error", {
message: "Authentication timed out.",
});
authenticatedClient.disconnect();
}
}, this.CONNECTION_TIMEOUT_MS);
try {
const token = this.extractTokenFromHandshake(authenticatedClient);
if (!token) {
this.logger.warn(`Terminal client ${authenticatedClient.id} connected without token`);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: no token provided.",
});
authenticatedClient.disconnect();
clearTimeout(timeoutId);
return;
}
const sessionData = await this.authService.verifySession(token);
if (!sessionData) {
this.logger.warn(`Terminal client ${authenticatedClient.id} has invalid token`);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: invalid or expired token.",
});
authenticatedClient.disconnect();
clearTimeout(timeoutId);
return;
}
const user = sessionData.user as { id: string };
const userId = user.id;
const workspaceMembership = await this.prisma.workspaceMember.findFirst({
where: { userId },
select: { workspaceId: true, userId: true, role: true },
});
if (!workspaceMembership) {
this.logger.warn(`Terminal user ${userId} has no workspace access`);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: no workspace access.",
});
authenticatedClient.disconnect();
clearTimeout(timeoutId);
return;
}
authenticatedClient.data.userId = userId;
authenticatedClient.data.workspaceId = workspaceMembership.workspaceId;
// Join workspace-scoped terminal room
const room = this.getWorkspaceRoom(workspaceMembership.workspaceId);
await authenticatedClient.join(room);
clearTimeout(timeoutId);
this.logger.log(
`Terminal client ${authenticatedClient.id} connected (user: ${userId}, workspace: ${workspaceMembership.workspaceId})`
);
} catch (error) {
clearTimeout(timeoutId);
this.logger.error(
`Authentication failed for terminal client ${authenticatedClient.id}:`,
error instanceof Error ? error.message : "Unknown error"
);
authenticatedClient.emit("terminal:error", {
message: "Authentication failed: an unexpected error occurred.",
});
authenticatedClient.disconnect();
}
}
/**
* Clean up all PTY sessions for this client's workspace on disconnect.
*/
handleDisconnect(client: Socket): void {
const authenticatedClient = client as AuthenticatedSocket;
const { workspaceId, userId } = authenticatedClient.data;
if (workspaceId) {
this.terminalService.closeWorkspaceSessions(workspaceId);
const room = this.getWorkspaceRoom(workspaceId);
void authenticatedClient.leave(room);
this.logger.log(
`Terminal client ${authenticatedClient.id} disconnected (user: ${userId ?? "unknown"}, workspace: ${workspaceId})`
);
} else {
this.logger.debug(`Terminal client ${authenticatedClient.id} disconnected (unauthenticated)`);
}
}
// ==========================================
// Terminal events
// ==========================================
/**
* Spawn a new PTY session for the connected client.
*
* Emits `terminal:created` with { sessionId, name, cols, rows } on success.
* Emits `terminal:error` on failure.
*/
@SubscribeMessage("terminal:create")
async handleCreate(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
// Validate DTO
const dto = plainToInstance(CreateTerminalDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
try {
const result = this.terminalService.createSession(authenticatedClient, {
workspaceId,
socketId: authenticatedClient.id,
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.cols !== undefined ? { cols: dto.cols } : {}),
...(dto.rows !== undefined ? { rows: dto.rows } : {}),
...(dto.cwd !== undefined ? { cwd: dto.cwd } : {}),
});
authenticatedClient.emit("terminal:created", {
sessionId: result.sessionId,
name: result.name,
cols: result.cols,
rows: result.rows,
});
this.logger.log(
`Terminal session ${result.sessionId} created for client ${authenticatedClient.id} (workspace: ${workspaceId})`
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(
`Failed to create terminal session for client ${authenticatedClient.id}: ${message}`
);
authenticatedClient.emit("terminal:error", { message });
}
}
/**
* Write input data to an existing PTY session.
*
* Emits `terminal:error` if the session is not found or unauthorized.
*/
@SubscribeMessage("terminal:input")
async handleInput(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
const dto = plainToInstance(TerminalInputDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
});
return;
}
try {
this.terminalService.writeToSession(dto.sessionId, dto.data);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to write to terminal session ${dto.sessionId}: ${message}`);
authenticatedClient.emit("terminal:error", { message });
}
}
/**
* Resize an existing PTY session.
*
* Emits `terminal:error` if the session is not found or unauthorized.
*/
@SubscribeMessage("terminal:resize")
async handleResize(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
const dto = plainToInstance(TerminalResizeDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
});
return;
}
try {
this.terminalService.resizeSession(dto.sessionId, dto.cols, dto.rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to resize terminal session ${dto.sessionId}: ${message}`);
authenticatedClient.emit("terminal:error", { message });
}
}
/**
* Kill and close an existing PTY session.
*
* Emits `terminal:error` if the session is not found or unauthorized.
*/
@SubscribeMessage("terminal:close")
async handleClose(client: Socket, payload: unknown): Promise<void> {
const authenticatedClient = client as AuthenticatedSocket;
const { userId, workspaceId } = authenticatedClient.data;
if (!userId || !workspaceId) {
authenticatedClient.emit("terminal:error", {
message: "Not authenticated. Connect with a valid token.",
});
return;
}
const dto = plainToInstance(CloseTerminalDto, payload ?? {});
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
authenticatedClient.emit("terminal:error", {
message: `Invalid payload: ${messages}`,
});
return;
}
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
});
return;
}
const closed = this.terminalService.closeSession(dto.sessionId);
if (!closed) {
authenticatedClient.emit("terminal:error", {
message: `Terminal session ${dto.sessionId} not found.`,
});
return;
}
this.logger.log(`Terminal session ${dto.sessionId} closed by client ${authenticatedClient.id}`);
}
// ==========================================
// Private helpers
// ==========================================
/**
* Extract authentication token from Socket.IO handshake.
* Checks auth.token, query.token, and Authorization header (in that order).
*/
private extractTokenFromHandshake(client: Socket): string | undefined {
const authToken = client.handshake.auth.token as unknown;
if (typeof authToken === "string" && authToken.length > 0) {
return authToken;
}
const queryToken = client.handshake.query.token as unknown;
if (typeof queryToken === "string" && queryToken.length > 0) {
return queryToken;
}
const authHeader = client.handshake.headers.authorization as unknown;
if (typeof authHeader === "string") {
const parts = authHeader.split(" ");
const [type, token] = parts;
if (type === "Bearer" && token) {
return token;
}
}
return undefined;
}
/**
* Get the workspace-scoped room name for the terminal namespace.
*/
private getWorkspaceRoom(workspaceId: string): string {
return `terminal:${workspaceId}`;
}
}

View File

@@ -0,0 +1,31 @@
/**
* TerminalModule
*
* NestJS module for WebSocket-based terminal sessions via node-pty.
*
* Imports:
* - AuthModule for WebSocket authentication (verifySession)
* - PrismaModule for workspace membership queries and session persistence
*
* Providers:
* - TerminalService: manages PTY session lifecycle (in-memory)
* - TerminalSessionService: persists session records to the database
* - TerminalGateway: WebSocket gateway on /terminal namespace
*
* The module does not export providers; terminal sessions are
* self-contained within this module.
*/
import { Module } from "@nestjs/common";
import { TerminalGateway } from "./terminal.gateway";
import { TerminalService } from "./terminal.service";
import { TerminalSessionService } from "./terminal-session.service";
import { AuthModule } from "../auth/auth.module";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [AuthModule, PrismaModule],
providers: [TerminalGateway, TerminalService, TerminalSessionService],
exports: [TerminalSessionService],
})
export class TerminalModule {}

View File

@@ -0,0 +1,337 @@
/**
* TerminalService Tests
*
* Unit tests for PTY session management: create, write, resize, close,
* workspace cleanup, and access control.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import type { Socket } from "socket.io";
import { TerminalService, MAX_SESSIONS_PER_WORKSPACE } from "./terminal.service";
// ==========================================
// Mocks
// ==========================================
// Mock node-pty before importing service
const mockPtyProcess = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
pid: 12345,
};
vi.mock("node-pty", () => ({
spawn: vi.fn(() => mockPtyProcess),
}));
function createMockSocket(id = "socket-1"): Socket {
return {
id,
emit: vi.fn(),
join: vi.fn(),
leave: vi.fn(),
disconnect: vi.fn(),
data: {},
} as unknown as Socket;
}
// ==========================================
// Tests
// ==========================================
describe("TerminalService", () => {
let service: TerminalService;
let mockSocket: Socket;
beforeEach(() => {
vi.clearAllMocks();
// Reset mock implementations
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
mockPtyProcess.onExit.mockImplementation(
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
);
service = new TerminalService();
mockSocket = createMockSocket();
});
afterEach(() => {
vi.clearAllMocks();
});
// ==========================================
// createSession
// ==========================================
describe("createSession", () => {
it("should create a PTY session and return sessionId", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(result.sessionId).toBeDefined();
expect(typeof result.sessionId).toBe("string");
expect(result.cols).toBe(80);
expect(result.rows).toBe(24);
});
it("should use provided cols and rows", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
cols: 120,
rows: 40,
});
expect(result.cols).toBe(120);
expect(result.rows).toBe(40);
});
it("should return the provided session name", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
name: "my-terminal",
});
expect(result.name).toBe("my-terminal");
});
it("should wire PTY onData to emit terminal:output", () => {
let dataCallback: ((data: string) => void) | undefined;
mockPtyProcess.onData.mockImplementation((cb: (data: string) => void) => {
dataCallback = cb;
});
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(dataCallback).toBeDefined();
dataCallback!("hello world");
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:output", {
sessionId: result.sessionId,
data: "hello world",
});
});
it("should wire PTY onExit to emit terminal:exit and cleanup", () => {
let exitCallback: ((e: { exitCode: number; signal?: number }) => void) | undefined;
mockPtyProcess.onExit.mockImplementation(
(cb: (e: { exitCode: number; signal?: number }) => void) => {
exitCallback = cb;
}
);
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(exitCallback).toBeDefined();
exitCallback!({ exitCode: 0 });
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:exit", {
sessionId: result.sessionId,
exitCode: 0,
signal: undefined,
});
// Session should be cleaned up
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
});
it("should throw when workspace session limit is reached", () => {
const limit = MAX_SESSIONS_PER_WORKSPACE;
for (let i = 0; i < limit; i++) {
service.createSession(createMockSocket(`socket-${String(i)}`), {
workspaceId: "ws-limit",
socketId: `socket-${String(i)}`,
});
}
expect(() =>
service.createSession(createMockSocket("socket-overflow"), {
workspaceId: "ws-limit",
socketId: "socket-overflow",
})
).toThrow(/maximum/i);
});
it("should allow sessions in different workspaces independently", () => {
service.createSession(mockSocket, { workspaceId: "ws-a", socketId: "s1" });
service.createSession(createMockSocket("s2"), { workspaceId: "ws-b", socketId: "s2" });
expect(service.getWorkspaceSessionCount("ws-a")).toBe(1);
expect(service.getWorkspaceSessionCount("ws-b")).toBe(1);
});
});
// ==========================================
// writeToSession
// ==========================================
describe("writeToSession", () => {
it("should write data to PTY", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
service.writeToSession(result.sessionId, "ls -la\n");
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
});
it("should throw for unknown sessionId", () => {
expect(() => service.writeToSession("nonexistent-id", "data")).toThrow(/not found/i);
});
});
// ==========================================
// resizeSession
// ==========================================
describe("resizeSession", () => {
it("should resize PTY dimensions", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
service.resizeSession(result.sessionId, 132, 50);
expect(mockPtyProcess.resize).toHaveBeenCalledWith(132, 50);
});
it("should throw for unknown sessionId", () => {
expect(() => service.resizeSession("nonexistent-id", 80, 24)).toThrow(/not found/i);
});
});
// ==========================================
// closeSession
// ==========================================
describe("closeSession", () => {
it("should kill PTY and return true for existing session", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
const closed = service.closeSession(result.sessionId);
expect(closed).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalled();
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
});
it("should return false for nonexistent sessionId", () => {
const closed = service.closeSession("does-not-exist");
expect(closed).toBe(false);
});
it("should clean up workspace tracking after close", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(service.getWorkspaceSessionCount("ws-1")).toBe(1);
service.closeSession(result.sessionId);
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
});
it("should not throw if PTY kill throws", () => {
mockPtyProcess.kill.mockImplementationOnce(() => {
throw new Error("PTY already dead");
});
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(() => service.closeSession(result.sessionId)).not.toThrow();
});
});
// ==========================================
// closeWorkspaceSessions
// ==========================================
describe("closeWorkspaceSessions", () => {
it("should kill all sessions for a workspace", () => {
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
service.createSession(createMockSocket("s2"), { workspaceId: "ws-1", socketId: "s2" });
expect(service.getWorkspaceSessionCount("ws-1")).toBe(2);
service.closeWorkspaceSessions("ws-1");
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
expect(mockPtyProcess.kill).toHaveBeenCalledTimes(2);
});
it("should not affect sessions in other workspaces", () => {
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
service.createSession(createMockSocket("s2"), { workspaceId: "ws-2", socketId: "s2" });
service.closeWorkspaceSessions("ws-1");
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
expect(service.getWorkspaceSessionCount("ws-2")).toBe(1);
});
it("should not throw for workspaces with no sessions", () => {
expect(() => service.closeWorkspaceSessions("ws-nonexistent")).not.toThrow();
});
});
// ==========================================
// sessionBelongsToWorkspace
// ==========================================
describe("sessionBelongsToWorkspace", () => {
it("should return true for a session belonging to the workspace", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(true);
});
it("should return false for a session in a different workspace", () => {
const result = service.createSession(mockSocket, {
workspaceId: "ws-1",
socketId: "socket-1",
});
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-2")).toBe(false);
});
it("should return false for a nonexistent sessionId", () => {
expect(service.sessionBelongsToWorkspace("no-such-id", "ws-1")).toBe(false);
});
});
// ==========================================
// getWorkspaceSessionCount
// ==========================================
describe("getWorkspaceSessionCount", () => {
it("should return 0 for workspace with no sessions", () => {
expect(service.getWorkspaceSessionCount("empty-ws")).toBe(0);
});
it("should track session count accurately", () => {
service.createSession(mockSocket, { workspaceId: "ws-count", socketId: "s1" });
expect(service.getWorkspaceSessionCount("ws-count")).toBe(1);
service.createSession(createMockSocket("s2"), { workspaceId: "ws-count", socketId: "s2" });
expect(service.getWorkspaceSessionCount("ws-count")).toBe(2);
});
});
});

View File

@@ -0,0 +1,251 @@
/**
* TerminalService
*
* Manages PTY (pseudo-terminal) sessions for workspace users.
* Spawns real shell processes via node-pty, streams I/O to connected sockets,
* and enforces per-workspace session limits.
*
* Session lifecycle:
* - createSession: spawn a new PTY, wire onData/onExit, return sessionId
* - writeToSession: send input data to PTY stdin
* - resizeSession: resize PTY dimensions (cols x rows)
* - closeSession: kill PTY process, emit terminal:exit, cleanup
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
*/
import { Injectable, Logger } from "@nestjs/common";
import * as pty from "node-pty";
import type { Socket } from "socket.io";
import { randomUUID } from "node:crypto";
/** Maximum concurrent PTY sessions per workspace */
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
10
);
/** Default PTY dimensions */
const DEFAULT_COLS = 80;
const DEFAULT_ROWS = 24;
export interface TerminalSession {
sessionId: string;
workspaceId: string;
pty: pty.IPty;
name?: string;
createdAt: Date;
}
export interface CreateSessionOptions {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
workspaceId: string;
socketId: string;
}
export interface SessionCreatedResult {
sessionId: string;
name?: string;
cols: number;
rows: number;
}
@Injectable()
export class TerminalService {
private readonly logger = new Logger(TerminalService.name);
/**
* Map of sessionId -> TerminalSession
*/
private readonly sessions = new Map<string, TerminalSession>();
/**
* Map of workspaceId -> Set<sessionId> for fast per-workspace lookups
*/
private readonly workspaceSessions = new Map<string, Set<string>>();
/**
* Create a new PTY session for the given workspace and socket.
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
*
* @throws Error if workspace session limit is exceeded
*/
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
const { workspaceId, name, cwd, socketId } = options;
const cols = options.cols ?? DEFAULT_COLS;
const rows = options.rows ?? DEFAULT_ROWS;
// Enforce per-workspace session limit
const workspaceSessionIds = this.workspaceSessions.get(workspaceId) ?? new Set<string>();
if (workspaceSessionIds.size >= MAX_SESSIONS_PER_WORKSPACE) {
throw new Error(
`Workspace ${workspaceId} has reached the maximum of ${String(MAX_SESSIONS_PER_WORKSPACE)} concurrent terminal sessions`
);
}
const sessionId = randomUUID();
const shell = process.env.SHELL ?? "/bin/bash";
this.logger.log(
`Spawning PTY session ${sessionId} for workspace ${workspaceId} (socket: ${socketId}, shell: ${shell}, ${String(cols)}x${String(rows)})`
);
const ptyProcess = pty.spawn(shell, [], {
name: "xterm-256color",
cols,
rows,
cwd: cwd ?? process.cwd(),
env: process.env as Record<string, string>,
});
const session: TerminalSession = {
sessionId,
workspaceId,
pty: ptyProcess,
...(name !== undefined ? { name } : {}),
createdAt: new Date(),
};
this.sessions.set(sessionId, session);
// Track by workspace
if (!this.workspaceSessions.has(workspaceId)) {
this.workspaceSessions.set(workspaceId, new Set());
}
const wsSet = this.workspaceSessions.get(workspaceId);
if (wsSet) {
wsSet.add(sessionId);
}
// Wire PTY stdout/stderr -> terminal:output
ptyProcess.onData((data: string) => {
socket.emit("terminal:output", { sessionId, data });
});
// Wire PTY exit -> terminal:exit, cleanup
ptyProcess.onExit(({ exitCode, signal }) => {
this.logger.log(
`PTY session ${sessionId} exited (exitCode: ${String(exitCode)}, signal: ${String(signal ?? "none")})`
);
socket.emit("terminal:exit", { sessionId, exitCode, signal });
this.cleanupSession(sessionId, workspaceId);
});
return { sessionId, ...(name !== undefined ? { name } : {}), cols, rows };
}
/**
* Write input data to a PTY session's stdin.
*
* @throws Error if session not found
*/
writeToSession(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Terminal session ${sessionId} not found`);
}
session.pty.write(data);
}
/**
* Resize a PTY session's terminal dimensions.
*
* @throws Error if session not found
*/
resizeSession(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Terminal session ${sessionId} not found`);
}
session.pty.resize(cols, rows);
this.logger.debug(`Resized PTY session ${sessionId} to ${String(cols)}x${String(rows)}`);
}
/**
* Kill and clean up a specific PTY session.
* Returns true if the session existed, false if it was already gone.
*/
closeSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
this.logger.log(`Closing PTY session ${sessionId} for workspace ${session.workspaceId}`);
try {
session.pty.kill();
} catch (error) {
this.logger.warn(
`Error killing PTY session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
);
}
this.cleanupSession(sessionId, session.workspaceId);
return true;
}
/**
* Close all PTY sessions for a workspace (called on client disconnect).
*/
closeWorkspaceSessions(workspaceId: string): void {
const sessionIds = this.workspaceSessions.get(workspaceId);
if (!sessionIds || sessionIds.size === 0) {
return;
}
this.logger.log(
`Closing ${String(sessionIds.size)} PTY session(s) for workspace ${workspaceId} (disconnect)`
);
// Copy to array to avoid mutation during iteration
const ids = Array.from(sessionIds);
for (const sessionId of ids) {
const session = this.sessions.get(sessionId);
if (session) {
try {
session.pty.kill();
} catch (error) {
this.logger.warn(
`Error killing PTY session ${sessionId} on disconnect: ${error instanceof Error ? error.message : String(error)}`
);
}
this.cleanupSession(sessionId, workspaceId);
}
}
}
/**
* Get the number of active sessions for a workspace.
*/
getWorkspaceSessionCount(workspaceId: string): number {
return this.workspaceSessions.get(workspaceId)?.size ?? 0;
}
/**
* Check if a session belongs to a given workspace.
* Used for access control in the gateway.
*/
sessionBelongsToWorkspace(sessionId: string, workspaceId: string): boolean {
const session = this.sessions.get(sessionId);
return session?.workspaceId === workspaceId;
}
/**
* Internal cleanup: remove session from tracking maps.
* Does NOT kill the PTY (caller is responsible).
*/
private cleanupSession(sessionId: string, workspaceId: string): void {
this.sessions.delete(sessionId);
const workspaceSessionIds = this.workspaceSessions.get(workspaceId);
if (workspaceSessionIds) {
workspaceSessionIds.delete(sessionId);
if (workspaceSessionIds.size === 0) {
this.workspaceSessions.delete(workspaceId);
}
}
}
}

View File

@@ -1,6 +1,8 @@
# Orchestrator Configuration
ORCHESTRATOR_PORT=3001
NODE_ENV=development
# AI provider for orchestrator agents: ollama, claude, openai
AI_PROVIDER=ollama
# Valkey
VALKEY_HOST=localhost
@@ -8,6 +10,7 @@ VALKEY_PORT=6379
VALKEY_URL=redis://localhost:6379
# Claude API
# Required only when AI_PROVIDER=claude.
CLAUDE_API_KEY=your-api-key-here
# Docker

View File

@@ -45,12 +45,22 @@ Monitored via `apps/web/` (Agent Dashboard).
### Agents
| Method | Path | Description |
| ------ | ------------------------- | ---------------------- |
| POST | `/agents/spawn` | Spawn a new agent |
| GET | `/agents/:agentId/status` | Get agent status |
| POST | `/agents/:agentId/kill` | Kill a single agent |
| POST | `/agents/kill-all` | Kill all active agents |
| Method | Path | Description |
| ------ | ------------------------- | ------------------------- |
| POST | `/agents/spawn` | Spawn a new agent |
| GET | `/agents/:agentId/status` | Get agent status |
| POST | `/agents/:agentId/kill` | Kill a single agent |
| POST | `/agents/kill-all` | Kill all active agents |
| GET | `/agents/events` | SSE lifecycle/task events |
| GET | `/agents/events/recent` | Recent events (polling) |
### Queue
| Method | Path | Description |
| ------ | --------------- | ---------------------------- |
| GET | `/queue/stats` | Queue depth and worker stats |
| POST | `/queue/pause` | Pause queue processing |
| POST | `/queue/resume` | Resume queue processing |
#### POST /agents/spawn
@@ -176,14 +186,18 @@ pnpm --filter @mosaic/orchestrator lint
Environment variables loaded via `@nestjs/config`. Key variables:
| Variable | Description |
| ------------------- | -------------------------------------- |
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
| `CLAUDE_API_KEY` | Claude API key for agents |
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
| `COORDINATOR_URL` | Quality Coordinator base URL |
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
| Variable | Description |
| -------------------------------- | ------------------------------------------------------------ |
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
| `AI_PROVIDER` | LLM provider for orchestrator (`ollama`, `claude`, `openai`) |
| `CLAUDE_API_KEY` | Required only when `AI_PROVIDER=claude` |
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
| `COORDINATOR_URL` | Quality Coordinator base URL |
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
| `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) |
| `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) |
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) |
## Related Documentation

View File

@@ -192,7 +192,8 @@ LABEL com.mosaic.security.non-root=true
Sensitive configuration is passed via environment variables:
- `CLAUDE_API_KEY`: Claude API credentials
- `AI_PROVIDER`: Orchestrator LLM provider
- `CLAUDE_API_KEY`: Claude credentials (required only for `AI_PROVIDER=claude`)
- `VALKEY_URL`: Cache connection string
**Best Practices:**

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { randomUUID } from "crypto";
import { ValkeyService } from "../../valkey/valkey.service";
import type { EventHandler, OrchestratorEvent } from "../../valkey/types";
type UnsubscribeFn = () => void;
const MAX_RECENT_EVENTS = 500;
@Injectable()
export class AgentEventsService implements OnModuleInit {
private readonly logger = new Logger(AgentEventsService.name);
private readonly subscribers = new Map<string, EventHandler>();
private readonly recentEvents: OrchestratorEvent[] = [];
private connected = false;
constructor(private readonly valkeyService: ValkeyService) {}
async onModuleInit(): Promise<void> {
if (this.connected) return;
await this.valkeyService.subscribeToEvents(
(event) => {
this.appendRecentEvent(event);
this.subscribers.forEach((handler) => {
void handler(event);
});
},
(error, _raw, channel) => {
this.logger.warn(`Event stream parse/validation warning on ${channel}: ${error.message}`);
}
);
this.connected = true;
this.logger.log("Agent event stream subscription active");
}
subscribe(handler: EventHandler): UnsubscribeFn {
const id = randomUUID();
this.subscribers.set(id, handler);
return () => {
this.subscribers.delete(id);
};
}
async getInitialSnapshot(): Promise<{
type: "stream.snapshot";
timestamp: string;
agents: number;
tasks: number;
}> {
const [agents, tasks] = await Promise.all([
this.valkeyService.listAgents(),
this.valkeyService.listTasks(),
]);
return {
type: "stream.snapshot",
timestamp: new Date().toISOString(),
agents: agents.length,
tasks: tasks.length,
};
}
createHeartbeat(): OrchestratorEvent {
return {
type: "task.processing",
timestamp: new Date().toISOString(),
data: {
heartbeat: true,
},
};
}
getRecentEvents(limit = 100): OrchestratorEvent[] {
const safeLimit = Math.min(Math.max(Math.floor(limit), 1), MAX_RECENT_EVENTS);
if (safeLimit >= this.recentEvents.length) {
return [...this.recentEvents];
}
return this.recentEvents.slice(-safeLimit);
}
private appendRecentEvent(event: OrchestratorEvent): void {
this.recentEvents.push(event);
if (this.recentEvents.length > MAX_RECENT_EVENTS) {
this.recentEvents.shift();
}
}
}

View File

@@ -4,6 +4,7 @@ import { QueueService } from "../../queue/queue.service";
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
import { KillswitchService } from "../../killswitch/killswitch.service";
import { AgentEventsService } from "./agent-events.service";
import type { KillAllResult } from "../../killswitch/killswitch.service";
describe("AgentsController - Killswitch Endpoints", () => {
@@ -20,6 +21,12 @@ describe("AgentsController - Killswitch Endpoints", () => {
};
let mockLifecycleService: {
getAgentLifecycleState: ReturnType<typeof vi.fn>;
registerSpawnedAgent: ReturnType<typeof vi.fn>;
};
let mockEventsService: {
subscribe: ReturnType<typeof vi.fn>;
getInitialSnapshot: ReturnType<typeof vi.fn>;
createHeartbeat: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
@@ -38,13 +45,30 @@ describe("AgentsController - Killswitch Endpoints", () => {
mockLifecycleService = {
getAgentLifecycleState: vi.fn(),
registerSpawnedAgent: vi.fn(),
};
mockEventsService = {
subscribe: vi.fn().mockReturnValue(() => {}),
getInitialSnapshot: vi.fn().mockResolvedValue({
type: "stream.snapshot",
timestamp: new Date().toISOString(),
agents: 0,
tasks: 0,
}),
createHeartbeat: vi.fn().mockReturnValue({
type: "task.processing",
timestamp: new Date().toISOString(),
data: { heartbeat: true },
}),
};
controller = new AgentsController(
mockQueueService as unknown as QueueService,
mockSpawnerService as unknown as AgentSpawnerService,
mockLifecycleService as unknown as AgentLifecycleService,
mockKillswitchService as unknown as KillswitchService
mockKillswitchService as unknown as KillswitchService,
mockEventsService as unknown as AgentEventsService
);
});

View File

@@ -3,6 +3,7 @@ import { QueueService } from "../../queue/queue.service";
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
import { KillswitchService } from "../../killswitch/killswitch.service";
import { AgentEventsService } from "./agent-events.service";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
describe("AgentsController", () => {
@@ -17,11 +18,18 @@ describe("AgentsController", () => {
};
let lifecycleService: {
getAgentLifecycleState: ReturnType<typeof vi.fn>;
registerSpawnedAgent: ReturnType<typeof vi.fn>;
};
let killswitchService: {
killAgent: ReturnType<typeof vi.fn>;
killAllAgents: ReturnType<typeof vi.fn>;
};
let eventsService: {
subscribe: ReturnType<typeof vi.fn>;
getInitialSnapshot: ReturnType<typeof vi.fn>;
createHeartbeat: ReturnType<typeof vi.fn>;
getRecentEvents: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Create mock services
@@ -37,6 +45,7 @@ describe("AgentsController", () => {
lifecycleService = {
getAgentLifecycleState: vi.fn(),
registerSpawnedAgent: vi.fn().mockResolvedValue(undefined),
};
killswitchService = {
@@ -44,12 +53,29 @@ describe("AgentsController", () => {
killAllAgents: vi.fn(),
};
eventsService = {
subscribe: vi.fn().mockReturnValue(() => {}),
getInitialSnapshot: vi.fn().mockResolvedValue({
type: "stream.snapshot",
timestamp: new Date().toISOString(),
agents: 0,
tasks: 0,
}),
createHeartbeat: vi.fn().mockReturnValue({
type: "task.processing",
timestamp: new Date().toISOString(),
data: { heartbeat: true },
}),
getRecentEvents: vi.fn().mockReturnValue([]),
};
// Create controller with mocked services
controller = new AgentsController(
queueService as unknown as QueueService,
spawnerService as unknown as AgentSpawnerService,
lifecycleService as unknown as AgentLifecycleService,
killswitchService as unknown as KillswitchService
killswitchService as unknown as KillswitchService,
eventsService as unknown as AgentEventsService
);
});
@@ -195,6 +221,10 @@ describe("AgentsController", () => {
expect(queueService.addTask).toHaveBeenCalledWith(validRequest.taskId, validRequest.context, {
priority: 5,
});
expect(lifecycleService.registerSpawnedAgent).toHaveBeenCalledWith(
agentId,
validRequest.taskId
);
expect(result).toEqual({
agentId,
status: "spawning",
@@ -334,4 +364,39 @@ describe("AgentsController", () => {
});
});
});
describe("getRecentEvents", () => {
it("should return recent events with default limit", () => {
eventsService.getRecentEvents.mockReturnValue([
{
type: "task.completed",
timestamp: "2026-02-17T15:00:00.000Z",
taskId: "task-123",
},
]);
const result = controller.getRecentEvents();
expect(eventsService.getRecentEvents).toHaveBeenCalledWith(100);
expect(result).toEqual({
events: [
{
type: "task.completed",
timestamp: "2026-02-17T15:00:00.000Z",
taskId: "task-123",
},
],
});
});
it("should parse and pass custom limit", () => {
controller.getRecentEvents("25");
expect(eventsService.getRecentEvents).toHaveBeenCalledWith(25);
});
it("should fallback to default when limit is invalid", () => {
controller.getRecentEvents("invalid");
expect(eventsService.getRecentEvents).toHaveBeenCalledWith(100);
});
});
});

View File

@@ -11,8 +11,12 @@ import {
HttpCode,
UseGuards,
ParseUUIDPipe,
Sse,
MessageEvent,
Query,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { Observable } from "rxjs";
import { QueueService } from "../../queue/queue.service";
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
@@ -20,6 +24,7 @@ import { KillswitchService } from "../../killswitch/killswitch.service";
import { SpawnAgentDto, SpawnAgentResponseDto } from "./dto/spawn-agent.dto";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard";
import { AgentEventsService } from "./agent-events.service";
/**
* Controller for agent management endpoints
@@ -41,7 +46,8 @@ export class AgentsController {
private readonly queueService: QueueService,
private readonly spawnerService: AgentSpawnerService,
private readonly lifecycleService: AgentLifecycleService,
private readonly killswitchService: KillswitchService
private readonly killswitchService: KillswitchService,
private readonly eventsService: AgentEventsService
) {}
/**
@@ -67,6 +73,9 @@ export class AgentsController {
context: dto.context,
});
// Persist initial lifecycle state in Valkey.
await this.lifecycleService.registerSpawnedAgent(spawnResponse.agentId, dto.taskId);
// Queue task in Valkey
await this.queueService.addTask(dto.taskId, dto.context, {
priority: 5, // Default priority
@@ -85,6 +94,55 @@ export class AgentsController {
}
}
/**
* Stream orchestrator events as server-sent events (SSE)
*/
@Sse("events")
@Throttle({ status: { limit: 200, ttl: 60000 } })
streamEvents(): Observable<MessageEvent> {
return new Observable<MessageEvent>((subscriber) => {
let isClosed = false;
const unsubscribe = this.eventsService.subscribe((event) => {
if (!isClosed) {
subscriber.next({ data: event });
}
});
void this.eventsService.getInitialSnapshot().then((snapshot) => {
if (!isClosed) {
subscriber.next({ data: snapshot });
}
});
const heartbeat = setInterval(() => {
if (!isClosed) {
subscriber.next({ data: this.eventsService.createHeartbeat() });
}
}, 15000);
return () => {
isClosed = true;
clearInterval(heartbeat);
unsubscribe();
};
});
}
/**
* Return recent orchestrator events for non-streaming consumers.
*/
@Get("events/recent")
@Throttle({ status: { limit: 200, ttl: 60000 } })
getRecentEvents(@Query("limit") limit?: string): {
events: ReturnType<AgentEventsService["getRecentEvents"]>;
} {
const parsedLimit = Number.parseInt(limit ?? "100", 10);
return {
events: this.eventsService.getRecentEvents(Number.isNaN(parsedLimit) ? 100 : parsedLimit),
};
}
/**
* List all agents
* @returns Array of all agent sessions with their status

View File

@@ -5,10 +5,11 @@ import { SpawnerModule } from "../../spawner/spawner.module";
import { KillswitchModule } from "../../killswitch/killswitch.module";
import { ValkeyModule } from "../../valkey/valkey.module";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
import { AgentEventsService } from "./agent-events.service";
@Module({
imports: [QueueModule, SpawnerModule, KillswitchModule, ValkeyModule],
controllers: [AgentsController],
providers: [OrchestratorApiKeyGuard],
providers: [OrchestratorApiKeyGuard, AgentEventsService],
})
export class AgentsModule {}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { QueueController } from "./queue.controller";
import { QueueModule } from "../../queue/queue.module";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
@Module({
imports: [QueueModule],
controllers: [QueueController],
providers: [OrchestratorApiKeyGuard],
})
export class QueueApiModule {}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { QueueController } from "./queue.controller";
import { QueueService } from "../../queue/queue.service";
describe("QueueController", () => {
let controller: QueueController;
let queueService: {
getStats: ReturnType<typeof vi.fn>;
pause: ReturnType<typeof vi.fn>;
resume: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
queueService = {
getStats: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
};
controller = new QueueController(queueService as unknown as QueueService);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should return queue stats", async () => {
queueService.getStats.mockResolvedValue({
pending: 5,
active: 1,
completed: 10,
failed: 2,
delayed: 0,
});
const result = await controller.getStats();
expect(queueService.getStats).toHaveBeenCalledOnce();
expect(result).toEqual({
pending: 5,
active: 1,
completed: 10,
failed: 2,
delayed: 0,
});
});
it("should pause queue processing", async () => {
queueService.pause.mockResolvedValue(undefined);
const result = await controller.pause();
expect(queueService.pause).toHaveBeenCalledOnce();
expect(result).toEqual({ message: "Queue processing paused" });
});
it("should resume queue processing", async () => {
queueService.resume.mockResolvedValue(undefined);
const result = await controller.resume();
expect(queueService.resume).toHaveBeenCalledOnce();
expect(result).toEqual({ message: "Queue processing resumed" });
});
});

View File

@@ -0,0 +1,39 @@
import { Controller, Get, HttpCode, Post, UseGuards } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { QueueService } from "../../queue/queue.service";
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
import { OrchestratorThrottlerGuard } from "../../common/guards/throttler.guard";
@Controller("queue")
@UseGuards(OrchestratorApiKeyGuard, OrchestratorThrottlerGuard)
export class QueueController {
constructor(private readonly queueService: QueueService) {}
@Get("stats")
@Throttle({ status: { limit: 200, ttl: 60000 } })
async getStats(): Promise<{
pending: number;
active: number;
completed: number;
failed: number;
delayed: number;
}> {
return this.queueService.getStats();
}
@Post("pause")
@Throttle({ strict: { limit: 10, ttl: 60000 } })
@HttpCode(200)
async pause(): Promise<{ message: string }> {
await this.queueService.pause();
return { message: "Queue processing paused" };
}
@Post("resume")
@Throttle({ strict: { limit: 10, ttl: 60000 } })
@HttpCode(200)
async resume(): Promise<{ message: string }> {
await this.queueService.resume();
return { message: "Queue processing resumed" };
}
}

View File

@@ -1,9 +1,10 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { BullModule } from "@nestjs/bullmq";
import { ThrottlerModule } from "@nestjs/throttler";
import { HealthModule } from "./api/health/health.module";
import { AgentsModule } from "./api/agents/agents.module";
import { QueueApiModule } from "./api/queue/queue-api.module";
import { CoordinatorModule } from "./coordinator/coordinator.module";
import { BudgetModule } from "./budget/budget.module";
import { CIModule } from "./ci";
@@ -21,11 +22,15 @@ import { orchestratorConfig } from "./config/orchestrator.config";
isGlobal: true,
load: [orchestratorConfig],
}),
BullModule.forRoot({
connection: {
host: process.env.VALKEY_HOST ?? "localhost",
port: parseInt(process.env.VALKEY_PORT ?? "6379"),
},
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get<string>("orchestrator.valkey.host", "localhost"),
port: configService.get<number>("orchestrator.valkey.port", 6379),
password: configService.get<string>("orchestrator.valkey.password"),
},
}),
}),
ThrottlerModule.forRoot([
{
@@ -46,6 +51,7 @@ import { orchestratorConfig } from "./config/orchestrator.config";
]),
HealthModule,
AgentsModule,
QueueApiModule,
CoordinatorModule,
BudgetModule,
CIModule,

View File

@@ -120,6 +120,42 @@ describe("orchestratorConfig", () => {
expect(config.valkey.port).toBe(6379);
expect(config.valkey.url).toBe("redis://localhost:6379");
});
it("should derive valkey host and port from VALKEY_URL when VALKEY_HOST/VALKEY_PORT are not set", () => {
delete process.env.VALKEY_HOST;
delete process.env.VALKEY_PORT;
process.env.VALKEY_URL = "redis://valkey:6380";
const config = orchestratorConfig();
expect(config.valkey.host).toBe("valkey");
expect(config.valkey.port).toBe(6380);
expect(config.valkey.url).toBe("redis://valkey:6380");
});
it("should derive valkey password from VALKEY_URL when VALKEY_PASSWORD is not set", () => {
delete process.env.VALKEY_PASSWORD;
delete process.env.VALKEY_HOST;
delete process.env.VALKEY_PORT;
process.env.VALKEY_URL = "redis://:url-secret@valkey:6379";
const config = orchestratorConfig();
expect(config.valkey.password).toBe("url-secret");
});
it("should prefer explicit valkey env vars over VALKEY_URL values", () => {
process.env.VALKEY_HOST = "explicit-host";
process.env.VALKEY_PORT = "6390";
process.env.VALKEY_PASSWORD = "explicit-password";
process.env.VALKEY_URL = "redis://:url-secret@valkey:6380";
const config = orchestratorConfig();
expect(config.valkey.host).toBe("explicit-host");
expect(config.valkey.port).toBe(6390);
expect(config.valkey.password).toBe("explicit-password");
});
});
describe("valkey timeout config (SEC-ORCH-28)", () => {
@@ -157,12 +193,12 @@ describe("orchestratorConfig", () => {
});
describe("spawner config", () => {
it("should use default maxConcurrentAgents of 20 when not set", () => {
it("should use default maxConcurrentAgents of 2 when not set", () => {
delete process.env.MAX_CONCURRENT_AGENTS;
const config = orchestratorConfig();
expect(config.spawner.maxConcurrentAgents).toBe(20);
expect(config.spawner.maxConcurrentAgents).toBe(2);
});
it("should use provided maxConcurrentAgents when MAX_CONCURRENT_AGENTS is set", () => {
@@ -181,4 +217,30 @@ describe("orchestratorConfig", () => {
expect(config.spawner.maxConcurrentAgents).toBe(10);
});
});
describe("AI provider config", () => {
it("should default aiProvider to ollama when unset", () => {
delete process.env.AI_PROVIDER;
const config = orchestratorConfig();
expect(config.aiProvider).toBe("ollama");
});
it("should normalize AI provider to lowercase", () => {
process.env.AI_PROVIDER = " cLaUdE ";
const config = orchestratorConfig();
expect(config.aiProvider).toBe("claude");
});
it("should fallback unsupported AI provider to ollama", () => {
process.env.AI_PROVIDER = "bad-provider";
const config = orchestratorConfig();
expect(config.aiProvider).toBe("ollama");
});
});
});

View File

@@ -1,55 +1,96 @@
import { registerAs } from "@nestjs/config";
export const orchestratorConfig = registerAs("orchestrator", () => ({
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
port: parseInt(process.env.ORCHESTRATOR_PORT ?? "3001", 10),
valkey: {
host: process.env.VALKEY_HOST ?? "localhost",
port: parseInt(process.env.VALKEY_PORT ?? "6379", 10),
password: process.env.VALKEY_PASSWORD,
url: process.env.VALKEY_URL ?? "redis://localhost:6379",
connectTimeout: parseInt(process.env.VALKEY_CONNECT_TIMEOUT_MS ?? "5000", 10),
commandTimeout: parseInt(process.env.VALKEY_COMMAND_TIMEOUT_MS ?? "3000", 10),
},
claude: {
apiKey: process.env.CLAUDE_API_KEY,
},
docker: {
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
},
git: {
userName: process.env.GIT_USER_NAME ?? "Mosaic Orchestrator",
userEmail: process.env.GIT_USER_EMAIL ?? "orchestrator@mosaicstack.dev",
},
killswitch: {
enabled: process.env.KILLSWITCH_ENABLED === "true",
},
sandbox: {
enabled: process.env.SANDBOX_ENABLED !== "false",
defaultImage: process.env.SANDBOX_DEFAULT_IMAGE ?? "node:20-alpine",
defaultMemoryMB: parseInt(process.env.SANDBOX_DEFAULT_MEMORY_MB ?? "512", 10),
defaultCpuLimit: parseFloat(process.env.SANDBOX_DEFAULT_CPU_LIMIT ?? "1.0"),
networkMode: process.env.SANDBOX_NETWORK_MODE ?? "bridge",
},
coordinator: {
url: process.env.COORDINATOR_URL ?? "http://localhost:8000",
timeout: parseInt(process.env.COORDINATOR_TIMEOUT_MS ?? "30000", 10),
retries: parseInt(process.env.COORDINATOR_RETRIES ?? "3", 10),
apiKey: process.env.COORDINATOR_API_KEY,
},
yolo: {
enabled: process.env.YOLO_MODE === "true",
},
spawner: {
maxConcurrentAgents: parseInt(process.env.MAX_CONCURRENT_AGENTS ?? "20", 10),
},
queue: {
completedRetentionCount: parseInt(process.env.QUEUE_COMPLETED_RETENTION_COUNT ?? "100", 10),
completedRetentionAgeSeconds: parseInt(
process.env.QUEUE_COMPLETED_RETENTION_AGE_S ?? "3600",
10
),
failedRetentionCount: parseInt(process.env.QUEUE_FAILED_RETENTION_COUNT ?? "1000", 10),
failedRetentionAgeSeconds: parseInt(process.env.QUEUE_FAILED_RETENTION_AGE_S ?? "86400", 10),
},
}));
const normalizeAiProvider = (): "ollama" | "claude" | "openai" => {
const provider = process.env.AI_PROVIDER?.trim().toLowerCase();
if (!provider) {
return "ollama";
}
if (provider !== "ollama" && provider !== "claude" && provider !== "openai") {
return "ollama";
}
return provider;
};
const parseValkeyUrl = (url: string): { host?: string; port?: number; password?: string } => {
try {
const parsed = new URL(url);
const port = parsed.port ? parseInt(parsed.port, 10) : undefined;
return {
host: parsed.hostname || undefined,
port: Number.isNaN(port) ? undefined : port,
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
};
} catch {
return {};
}
};
export const orchestratorConfig = registerAs("orchestrator", () => {
const valkeyUrl = process.env.VALKEY_URL ?? "redis://localhost:6379";
const parsedValkeyUrl = parseValkeyUrl(valkeyUrl);
return {
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
port: parseInt(process.env.ORCHESTRATOR_PORT ?? "3001", 10),
valkey: {
host: process.env.VALKEY_HOST ?? parsedValkeyUrl.host ?? "localhost",
port: parseInt(process.env.VALKEY_PORT ?? String(parsedValkeyUrl.port ?? 6379), 10),
password: process.env.VALKEY_PASSWORD ?? parsedValkeyUrl.password,
url: valkeyUrl,
connectTimeout: parseInt(process.env.VALKEY_CONNECT_TIMEOUT_MS ?? "5000", 10),
commandTimeout: parseInt(process.env.VALKEY_COMMAND_TIMEOUT_MS ?? "3000", 10),
},
claude: {
apiKey: process.env.CLAUDE_API_KEY,
},
aiProvider: normalizeAiProvider(),
docker: {
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
},
git: {
userName: process.env.GIT_USER_NAME ?? "Mosaic Orchestrator",
userEmail: process.env.GIT_USER_EMAIL ?? "orchestrator@mosaicstack.dev",
},
killswitch: {
enabled: process.env.KILLSWITCH_ENABLED === "true",
},
sandbox: {
enabled: process.env.SANDBOX_ENABLED !== "false",
defaultImage: process.env.SANDBOX_DEFAULT_IMAGE ?? "node:20-alpine",
defaultMemoryMB: parseInt(process.env.SANDBOX_DEFAULT_MEMORY_MB ?? "256", 10),
defaultCpuLimit: parseFloat(process.env.SANDBOX_DEFAULT_CPU_LIMIT ?? "1.0"),
networkMode: process.env.SANDBOX_NETWORK_MODE ?? "none",
},
coordinator: {
url: process.env.COORDINATOR_URL ?? "http://localhost:8000",
timeout: parseInt(process.env.COORDINATOR_TIMEOUT_MS ?? "30000", 10),
retries: parseInt(process.env.COORDINATOR_RETRIES ?? "3", 10),
apiKey: process.env.COORDINATOR_API_KEY,
},
yolo: {
enabled: process.env.YOLO_MODE === "true",
},
spawner: {
maxConcurrentAgents: parseInt(process.env.MAX_CONCURRENT_AGENTS ?? "2", 10),
sessionCleanupDelayMs: parseInt(process.env.SESSION_CLEANUP_DELAY_MS ?? "30000", 10),
},
queue: {
name: process.env.ORCHESTRATOR_QUEUE_NAME ?? "orchestrator-tasks",
maxRetries: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_RETRIES ?? "3", 10),
baseDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_BASE_DELAY_MS ?? "1000", 10),
maxDelay: parseInt(process.env.ORCHESTRATOR_QUEUE_MAX_DELAY_MS ?? "60000", 10),
concurrency: parseInt(process.env.ORCHESTRATOR_QUEUE_CONCURRENCY ?? "1", 10),
completedRetentionCount: parseInt(process.env.QUEUE_COMPLETED_RETENTION_COUNT ?? "100", 10),
completedRetentionAgeSeconds: parseInt(
process.env.QUEUE_COMPLETED_RETENTION_AGE_S ?? "3600",
10
),
failedRetentionCount: parseInt(process.env.QUEUE_FAILED_RETENTION_COUNT ?? "1000", 10),
failedRetentionAgeSeconds: parseInt(process.env.QUEUE_FAILED_RETENTION_AGE_S ?? "86400", 10),
},
};
});

View File

@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { QueueService } from "./queue.service";
import { ValkeyModule } from "../valkey/valkey.module";
import { SpawnerModule } from "../spawner/spawner.module";
@Module({
imports: [ConfigModule, ValkeyModule],
imports: [ConfigModule, ValkeyModule, SpawnerModule],
providers: [QueueService],
exports: [QueueService],
})

View File

@@ -991,12 +991,17 @@ describe("QueueService", () => {
success: true,
metadata: { attempt: 1 },
});
expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith("task-123", "executing");
expect(mockValkeyService.updateTaskStatus).toHaveBeenCalledWith(
"task-123",
"executing",
undefined
);
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith({
type: "task.processing",
type: "task.executing",
timestamp: expect.any(String),
taskId: "task-123",
data: { attempt: 1 },
agentId: undefined,
data: { attempt: 1, dispatchedByQueue: true },
});
});

View File

@@ -1,7 +1,9 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { Injectable, OnModuleDestroy, OnModuleInit, Optional, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Queue, Worker, Job } from "bullmq";
import { ValkeyService } from "../valkey/valkey.service";
import { AgentSpawnerService } from "../spawner/agent-spawner.service";
import { AgentLifecycleService } from "../spawner/agent-lifecycle.service";
import type { TaskContext } from "../valkey/types";
import type {
QueuedTask,
@@ -16,6 +18,7 @@ import type {
*/
@Injectable()
export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(QueueService.name);
private queue!: Queue<QueuedTask>;
private worker!: Worker<QueuedTask, TaskProcessingResult>;
private readonly queueName: string;
@@ -23,7 +26,9 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly valkeyService: ValkeyService,
private readonly configService: ConfigService
private readonly configService: ConfigService,
@Optional() private readonly spawnerService?: AgentSpawnerService,
@Optional() private readonly lifecycleService?: AgentLifecycleService
) {
this.queueName = this.configService.get<string>(
"orchestrator.queue.name",
@@ -132,6 +137,16 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
context,
};
// Ensure task state exists before queue lifecycle updates.
const getTaskState = (this.valkeyService as Partial<ValkeyService>).getTaskState;
const createTask = (this.valkeyService as Partial<ValkeyService>).createTask;
if (typeof getTaskState === "function" && typeof createTask === "function") {
const existingTask = await getTaskState.call(this.valkeyService, taskId);
if (!existingTask) {
await createTask.call(this.valkeyService, taskId, context);
}
}
// Add to BullMQ queue
await this.queue.add(taskId, queuedTask, {
priority: 10 - priority + 1, // BullMQ: lower number = higher priority, so invert
@@ -214,23 +229,35 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
const { taskId } = job.data;
try {
const session = this.spawnerService?.findAgentSessionByTaskId(taskId);
const agentId = session?.agentId;
if (agentId) {
if (this.lifecycleService) {
await this.lifecycleService.transitionToRunning(agentId);
}
this.spawnerService?.setSessionState(agentId, "running");
}
// Update task state to executing
await this.valkeyService.updateTaskStatus(taskId, "executing");
await this.valkeyService.updateTaskStatus(taskId, "executing", agentId);
// Publish event
await this.valkeyService.publishEvent({
type: "task.processing",
type: "task.executing",
timestamp: new Date().toISOString(),
taskId,
data: { attempt: job.attemptsMade + 1 },
agentId,
data: {
attempt: job.attemptsMade + 1,
dispatchedByQueue: true,
},
});
// Task processing will be handled by agent spawner
// For now, just mark as processing
return {
success: true,
metadata: {
attempt: job.attemptsMade + 1,
...(agentId && { agentId }),
},
};
} catch (error) {
@@ -270,6 +297,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Handle task failure
*/
private async handleTaskFailure(taskId: string, error: Error): Promise<void> {
const session = this.spawnerService?.findAgentSessionByTaskId(taskId);
if (session) {
this.spawnerService?.setSessionState(session.agentId, "failed", error.message, new Date());
if (this.lifecycleService) {
await this.lifecycleService.transitionToFailed(session.agentId, error.message);
}
}
await this.valkeyService.updateTaskStatus(taskId, "failed", undefined, error.message);
await this.valkeyService.publishEvent({
@@ -284,12 +319,25 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Handle task completion
*/
private async handleTaskCompletion(taskId: string): Promise<void> {
const session = this.spawnerService?.findAgentSessionByTaskId(taskId);
if (session) {
this.spawnerService?.setSessionState(session.agentId, "completed", undefined, new Date());
if (this.lifecycleService) {
await this.lifecycleService.transitionToCompleted(session.agentId);
}
} else {
this.logger.warn(
`Queue completed task ${taskId} but no session was found; using queue-only completion state`
);
}
await this.valkeyService.updateTaskStatus(taskId, "completed");
await this.valkeyService.publishEvent({
type: "task.completed",
timestamp: new Date().toISOString(),
taskId,
...(session && { agentId: session.agentId }),
});
}
}

View File

@@ -37,6 +37,24 @@ export class AgentLifecycleService {
this.logger.log("AgentLifecycleService initialized");
}
/**
* Register a newly spawned agent in persistent state and emit spawned event.
*/
async registerSpawnedAgent(agentId: string, taskId: string): Promise<AgentState> {
await this.valkeyService.createAgent(agentId, taskId);
const createdState = await this.getAgentState(agentId);
const event: AgentEvent = {
type: "agent.spawned",
agentId,
taskId,
timestamp: new Date().toISOString(),
};
await this.valkeyService.publishEvent(event);
return createdState;
}
/**
* Acquire a per-agent mutex to serialize state transitions.
* Uses promise chaining: each caller chains onto the previous lock,

View File

@@ -12,6 +12,9 @@ describe("AgentSpawnerService", () => {
// Create mock ConfigService
mockConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
@@ -31,19 +34,80 @@ describe("AgentSpawnerService", () => {
expect(service).toBeDefined();
});
it("should initialize with Claude API key from config", () => {
it("should initialize with default AI provider when API key is omitted", () => {
const noClaudeConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
if (key === "orchestrator.spawner.sessionCleanupDelayMs") {
return 30000;
}
return undefined;
}),
} as unknown as ConfigService;
const serviceNoKey = new AgentSpawnerService(noClaudeConfigService);
expect(serviceNoKey).toBeDefined();
});
it("should initialize with Claude provider when key is present", () => {
expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.claude.apiKey");
});
it("should throw error if Claude API key is missing", () => {
it("should initialize with CLAUDE provider when API key is present", () => {
const claudeConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "claude";
}
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
return undefined;
}),
} as unknown as ConfigService;
const claudeService = new AgentSpawnerService(claudeConfigService);
expect(claudeService).toBeDefined();
});
it("should throw error if Claude API key is missing when provider is claude", () => {
const badConfigService = {
get: vi.fn(() => undefined),
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "claude";
}
return undefined;
}),
} as unknown as ConfigService;
expect(() => new AgentSpawnerService(badConfigService)).toThrow(
"CLAUDE_API_KEY is not configured"
"CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'"
);
});
it("should still initialize when CLAUDE_API_KEY is missing for non-Claude provider", () => {
const nonClaudeConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
return undefined;
}),
} as unknown as ConfigService;
expect(() => new AgentSpawnerService(nonClaudeConfigService)).not.toThrow();
});
});
describe("spawnAgent", () => {

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