Compare commits

..

107 Commits

Author SHA1 Message Date
4ece6dc643 chore(federation): M2 milestone close (FED-M2-13) (#503)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/publish Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 06:09:54 +00:00
194c3b603e docs(federation): M2 Step-CA setup guide + admin CLI reference (FED-M2-12) (#502)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-22 06:06:45 +00:00
fc1600b738 fix(federation): security hardening — OID verification, atomic activation, audit on failure (#501)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-22 06:02:52 +00:00
0ee5b14c68 test(federation): M2 E2E peer-add enrollment flow (FED-M2-10) (#500)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 05:37:06 +00:00
3eee176cc3 test(federation): M2 integration tests (FED-M2-09) (#499)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 05:08:24 +00:00
74fe60d8d6 feat(federation): admin controller + CLI federation commands (FED-M2-08) (#498)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 04:39:46 +00:00
0bfaa56e9e feat(federation): enrollment controller + single-use token flow (FED-M2-07) (#497)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 04:23:19 +00:00
01dd6b9fa1 feat(federation): grants service CRUD + status transitions (FED-M2-06) (#496)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 03:57:12 +00:00
1038ae76e1 feat(federation): Step-CA client service for grant certs (FED-M2-04) (#494)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 03:34:37 +00:00
bf082d95a0 feat(federation): seal federation peer client keys at rest (FED-M2-05) (#495)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 03:10:20 +00:00
bb24292cf7 fix(federation): healthcheck + restart policy for federated-test stacks (#492)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 02:56:40 +00:00
f2cda52e1a fix(deploy): bump gateway image digest to sha-9f1a081 [DEPLOY-IMG-FIX] (#491)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-04-22 02:35:19 +00:00
7d7cf012f0 feat(federation): scope schema validator [FED-M2-03] (#489)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-22 02:31:13 +00:00
c56dda74aa feat(federation): Step-CA sidecar in federated compose [FED-M2-02] (#490)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-22 02:21:49 +00:00
9f1a08185e docs(federation): S21 tracking — DEPLOY-01/02 done, IMG-FIX in flight, M2-01 in remediation (#487)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 02:02:36 +00:00
d2e408656b fix(docker): pnpm deploy for self-contained gateway runtime image (#488)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-22 02:02:29 +00:00
54c278b871 feat(db): federation schema — grants/peers/audit_log [FED-M2-01] (#486)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-22 02:02:21 +00:00
4dbd429203 feat(deploy): portainer stack template for federation test instances [DEPLOY-02] (#485)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-04-22 01:34:44 +00:00
b985d7bfe2 docs(federation): M2 mission planning — TASKS decomposition + manifest update (#483)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-22 01:24:00 +00:00
45e8f02c91 feat(mosaic-portainer): PORTAINER_INSECURE flag for self-signed TLS (#484)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-22 01:21:54 +00:00
54c422ab06 Merge pull request 'docs(federation): close FED-M1 milestone' (#481) from feat/federation-m1-close into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-04-20 02:20:43 +00:00
Jarvis
b9fb8aab57 docs(federation): close FED-M1 milestone
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
- TASKS.md: mark FED-M1-12 done with PR/issue/tag references
- MISSION-MANIFEST.md: phase=M1 complete, progress 1/7, M1 row done with PR range #470-#481, session log appended
- scratchpad: Session 19 entry covering M1-09 → M1-12 with PR ledger and M1 retrospective learnings

Refs #460
2026-04-19 21:12:52 -05:00
78841f228a docs(federation): operator setup + migration guides (FED-M1-11) (#480)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 02:07:15 +00:00
dc4afee848 fix(storage): redact credentials in driver errors + advisory lock (FED-M1-10) (#479)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-20 02:02:57 +00:00
1e2b8ac8de test(federation): standalone regression canary — no breakage from M1 (FED-M1-09) (#478)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 01:46:35 +00:00
15d849c166 test(storage): integration test for migrate-tier (FED-M1-08) + camelCase column fix (#477)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-20 01:40:02 +00:00
78251d4af8 test(federation): integration tests for federated tier gateway boot (FED-M1-07) (#476)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 01:13:10 +00:00
1a4b1ebbf1 feat(gateway,storage): mosaic gateway doctor with tier health JSON (FED-M1-06) (#475)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 01:00:39 +00:00
ccad30dd27 feat(storage): mosaic storage migrate-tier with dry-run + idempotency (FED-M1-05) (#474)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 00:35:08 +00:00
4c2b177eab feat(gateway): tier-detector with fail-fast PG/Valkey/pgvector probes (FED-M1-04) (#473)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 00:07:07 +00:00
58169f9979 feat(storage): pgvector adapter support gated on tier=federated (FED-M1-03) (#472)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 23:42:18 +00:00
51402bdb6d feat(infra): docker-compose.federated.yml overlay (FED-M1-02) (#471)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 23:21:31 +00:00
9c89c32684 feat(config): add federated tier + rename team→standalone (FED-M1-01) (#470)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-19 23:11:11 +00:00
8aabb8c5b2 docs(mission): author MVP rollup manifest, archive install-ux-v2 (#469)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 22:51:11 +00:00
66512550df docs(federation): PRD, milestones, mission manifest, and M1 task breakdown (#468)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 22:27:09 +00:00
46dd799548 docs(federation): PRD, milestones, mission manifest, and M1 task breakdown (#467)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 22:09:20 +00:00
5f03c05523 chore(release): @mosaicstack/mosaic 0.0.30 (#459)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-12 02:18:17 +00:00
c3f810bbd1 fix(mosaic): seed TOOLS.md from defaults on install (#458)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-12 02:02:21 +00:00
b2cbf898d7 docs(scratchpad): finalize yolo runtime hotfix evidence (#456)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Follow-up to mosaicstack/stack#455.

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-11 17:14:00 +00:00
b2cec8c6ba fix(mosaic): stop yolo runtime from leaking runtime name as first user message (#455)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Fixes mosaicstack/stack#454

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-11 16:57:43 +00:00
81c1775a03 chore(release): @mosaicstack/mosaic 0.0.29 (#453)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-08 00:42:54 +00:00
f64ec12f39 fix(installer): preserve credentials dir and seed STANDARDS.md (#452)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-08 00:40:49 +00:00
026382325c feat(framework): superpowers enforcement, typecheck hook, file-ownership rules (#451)
All checks were successful
ci/woodpecker/manual/ci Pipeline was successful
ci/woodpecker/manual/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-07 00:44:22 +00:00
1bfd8570d6 chore(release): @mosaicstack/mosaic 0.0.28 (#450) 2026-04-06 00:46:31 +00:00
312acd8bad chore: sweep mosaicstack/mosaic-stack → mosaicstack/stack + add short install URL (#448) 2026-04-06 00:39:56 +00:00
d08b969918 fix(mosaic): mask password input in TUI login prompt (#449) 2026-04-06 00:33:54 +00:00
051de0d8a9 docs: update README for mosaicstack/stack repo rename (#447) 2026-04-06 00:22:20 +00:00
bd76df1a50 feat(mosaic): drill-down main menu + provider-first flow + quick start (#446) 2026-04-06 00:15:23 +00:00
62b2ce2da1 docs: orchestrator close-out IUV-M02 (#445)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 23:50:55 +00:00
172bacb30f feat(mosaic): IUV-M02 — CORS/FQDN UX polish + skill installer rework (#444)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 23:44:07 +00:00
43667d7349 docs: orchestrator close-out IUV-M01 — mark tasks done, append session 2 (#443)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 22:40:08 +00:00
783884376c docs: mark IUV-M01 complete — mosaic-v0.0.26 released (#436) (#442)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 22:31:37 +00:00
c08aa6fa46 fix: add vitest.config.ts to eslint allowDefaultProject (#440 build fix) (#441)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 22:01:57 +00:00
0ae932ab34 fix: bootstrap hotfix — DTO erasure, wizard failure, port prefill, Pi SDK copy (mosaic-v0.0.26) (#440)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
2026-04-05 21:43:30 +00:00
a8cd52e88c docs: scaffold install-ux-v2 mission (#439)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 21:27:19 +00:00
a4c94d9a90 chore(release): @mosaicstack/mosaic 0.0.25 (#435)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 20:53:19 +00:00
cee838d22e docs: close out install-ux-hardening mission (#434)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 19:19:54 +00:00
732f8a49cf feat: unified first-run flow — merge wizard + gateway install (IUH-M03) (#433)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 19:13:02 +00:00
be917e2496 docs: mark IUH-M02 complete, start IUH-M03 (#432)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 18:02:21 +00:00
cd8b1f666d feat: wizard remediation — password mask, hooks preview, headless (IUH-M02) (#431)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 17:47:53 +00:00
8fa5995bde docs: scaffold install-ux-hardening mission + archive cli-unification (#430)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 17:15:39 +00:00
25cada7735 feat: mosaic uninstall (IUH-M01) (#429)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 17:06:21 +00:00
be6553101c docs: finalize CLI unification mission at mosaic-v0.0.24 (#424)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 14:54:48 +00:00
417805f330 fix: bump memory/queue/storage to 0.0.4 to force republish (#423)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 14:39:15 +00:00
2472ce52e8 fix: bump stale sub-package versions (brain/forge/log) (#422)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 14:26:30 +00:00
597eb232d7 fix: revert mosaic to 0.0.22 alpha + republish macp (#421)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 14:15:46 +00:00
afe997db82 docs: mission cli-unification-20260404 complete (#420)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 07:54:50 +00:00
b9d464de61 docs: CLI unification release v0.1.0 (M8) (#419)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 07:46:00 +00:00
872c124581 feat(mosaic): unified first-run UX wizard -> gateway install -> verify (#418)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 07:29:17 +00:00
a531029c5b feat(mosaic): mosaic telemetry command (M6 CU-06-01..05) (#417)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 07:06:42 +00:00
35ab619bd0 docs: session 2 orchestrator bookkeeping (M3/M4/M5 complete) (#416)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-05 07:06:40 +00:00
831193cdd8 fix(macp): align exports + add CLI smoke test (#415)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 06:57:42 +00:00
df460d5a49 feat(macp): mosaic macp CLI surface (#410)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 06:33:52 +00:00
119ff0eb1b fix(mosaic): gateway token recovery review remediations (#414)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 06:13:29 +00:00
3abd63ea5c Merge pull request 'feat(mosaic): mosaic auth CLI surface' (#413) from feat/mosaic-auth-cli into main
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 06:11:33 +00:00
641e4604d5 feat(forge): mosaic forge CLI surface (#412)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 06:08:50 +00:00
Jarvis
9b5ecc0171 feat(mosaic): add auth command and stage parallel agent changes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Picks up auth command and spec written by parallel agent, and updated
mosaic cli.ts wiring from parallel development during cli-unification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:58:03 -05:00
Jarvis
a00325da0e feat(forge): add registerForgeCommand for mosaic forge CLI surface
Adds mosaic forge run|status|resume|personas list subcommands to
@mosaicstack/forge, wires registerForgeCommand into the root mosaic CLI,
and ships a smoke test asserting command structure. Ref CU-05-01
cli-unification-20260404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:58:03 -05:00
4ebce3422d feat(log): mosaic log CLI surface (#407)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:57:22 +00:00
751e0ee330 feat(storage): mosaic storage CLI surface (#405)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:48:13 +00:00
54b2920ef3 feat(memory): mosaic memory CLI surface (#406)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:44:06 +00:00
5917016509 feat(mosaic): gateway token recovery via BetterAuth cookie (#411)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-04-05 05:43:49 +00:00
7b4f1d249d feat(mosaic): top-level mosaic config command (#408)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 05:37:05 +00:00
5425f9268e feat(queue): mosaic queue CLI surface (#404)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 05:27:59 +00:00
febd866098 feat(brain): mosaic brain CLI surface (#403)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:20:44 +00:00
2446593fff feat(mosaic): alphabetize and group mosaic --help output (#402)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:12:32 +00:00
651426cf2e docs(plan): gateway admin token recovery flow (#401)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 05:11:33 +00:00
cf46f6e0ae docs: capture planning decisions + session 1 handoff (#400)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 04:57:00 +00:00
6f15a84ccf docs: archive stale mission, scaffold CLI unification mission (#399)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 04:47:54 +00:00
c39433c361 chore: remove legacy @mosaicstack/cli package (#398)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 04:39:46 +00:00
257796ce87 Merge pull request 'chore: bump @mosaicstack/mosaic to 0.0.21 for republish' (#397) from chore/bump-mosaic-0.0.21 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 04:12:05 +00:00
Jarvis
2357602f50 chore: bump @mosaicstack/mosaic to 0.0.21 for publish
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-04-04 23:09:52 -05:00
1230f6b984 ci: fail publish pipeline loudly on registry/auth/network errors (#396)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 03:58:35 +00:00
14b775f1b9 Merge pull request 'fix: populate KNOWN_PACKAGES for mosaic update command' (#395) from fix/populate-known-packages-list into main
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
Reviewed-on: mosaicstack/mosaic-stack#395
2026-04-05 03:52:57 +00:00
Jarvis
c7691d9807 fix: populate KNOWN_PACKAGES with all workspace packages for 'mosaic update'
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
- Remove @mosaicstack/cli (absorbed into @mosaicstack/mosaic)
- Add all 21 remaining workspace packages so the multi-package
  update checker actually covers every published package
2026-04-04 22:49:45 -05:00
9a53d55678 Merge pull request 'fix: update Gitea org references from mosaic/ to mosaicstack/' (#394) from fix/gitea-org-rename into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: mosaicstack/mosaic-stack#394
2026-04-05 03:35:11 +00:00
Jarvis
31008ef7ff fix: update Gitea org references from mosaic/ to mosaicstack/
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
- Update all package.json repo URLs (mosaic/mosaic-stack → mosaicstack/mosaic-stack)
- Update npm registry URLs (/api/packages/mosaic/npm → /api/packages/mosaicstack/npm)
- Update woodpecker publish destinations
- Update tools/install.sh registry and repo base URLs
2026-04-04 22:31:20 -05:00
621ab260c0 fix(mosaic): resumable gateway install + prominent admin token (#393)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/manual/publish Pipeline failed
ci/woodpecker/manual/ci Pipeline failed
2026-04-05 03:19:07 +00:00
2b1840214e Merge pull request 'fix: rename @mosaic/* packages to @mosaicstack/*' (#392) from fix/rename-mosaic-scope-391 into main
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 03:11:55 +00:00
Jarvis
5cfccc2ead fix(mosaic): remove unused hasUpdate variable in formatAllPackagesTable
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Fixes lint error:
@typescript-eslint/no-unused-vars on hasUpdate
2026-04-04 22:01:01 -05:00
Jarvis
774b76447d fix: rename all packages from @mosaic/* to @mosaicstack/*
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
- Updated all package.json name fields and dependency references
- Updated all TypeScript/JavaScript imports
- Updated .woodpecker/publish.yml filters and registry paths
- Updated tools/install.sh scope default
- Updated .npmrc registry paths (worktree + host)
- Enhanced update-checker.ts with checkForAllUpdates() multi-package support
- Updated CLI update command to show table of all packages
- Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand
- Marked checkForUpdate() with @deprecated JSDoc

Closes #391
2026-04-04 21:43:23 -05:00
80994bdc8e fix(packages): bump db/memory/queue for PGlite + adapter factories (#389)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 02:20:23 +00:00
2e31626f87 fix: simplify updater to @mosaic/mosaic only, add explicit tea repo/login flags (#388)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 02:09:23 +00:00
255ba46a4d fix(packages): republish @mosaic/config and bump dependents (#386)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 01:56:57 +00:00
10285933a0 fix: retarget updater to @mosaic/mosaic (#384)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 01:52:30 +00:00
543388e18b fix(mosaic): resolve framework scripts via import.meta.url (#385)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Fixes #383 — resolveTool now uses fileURLToPath(import.meta.url). Adds package.json/framework subpath exports. Bumps @mosaic/mosaic to 0.0.18.
2026-04-05 01:41:46 +00:00
07a1f5d594 Merge pull request 'feat(mosaic): merge @mosaic/cli into @mosaic/mosaic' (#381) from fix/merge-cli-into-mosaic into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 01:11:33 +00:00
410 changed files with 37746 additions and 7856 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ coverage
*.tsbuildinfo *.tsbuildinfo
.pnpm-store .pnpm-store
docs/reports/ docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example
infra/step-ca/dev-password

2
.npmrc
View File

@@ -1 +1 @@
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/ @mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/

View File

@@ -59,7 +59,7 @@ steps:
sleep 1 sleep 1
done done
# Run migrations (DATABASE_URL is set in environment above) # Run migrations (DATABASE_URL is set in environment above)
- pnpm --filter @mosaic/db run db:migrate - pnpm --filter @mosaicstack/db run db:migrate
# Run all tests # Run all tests
- pnpm test - pnpm test
depends_on: depends_on:

View File

@@ -33,15 +33,44 @@ steps:
- *enable_pnpm - *enable_pnpm
# Configure auth for Gitea npm registry # Configure auth for Gitea npm registry
- | - |
echo "//git.mosaicstack.dev/api/packages/mosaic/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/" >> ~/.npmrc echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
# Publish non-private packages to Gitea (--no-git-checks skips dirty/branch checks in CI) # Publish non-private packages to Gitea.
# --filter excludes web (private) #
- > # The only publish failure we tolerate is "version already exists" —
pnpm --filter "@mosaic/*" # that legitimately happens when only some packages were bumped in
--filter "!@mosaic/web" # the merge. Any other failure (registry 404, auth error, network
publish --no-git-checks --access public # error) MUST fail the pipeline loudly: the previous
|| echo "[publish] Some packages may already exist at this version — continuing" # `|| echo "... continuing"` fallback silently hid a 404 from the
# Gitea org rename and caused every @mosaicstack/* publish to fall
# on the floor while CI still reported green.
- |
# Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
set +e
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
EXIT=$?
set -e
cat /tmp/publish.log
if [ "$EXIT" -eq 0 ]; then
echo "[publish] all packages published successfully"
exit 0
fi
# Hard registry / auth / network errors → fatal. Match npm's own
# error lines specifically to avoid false positives on arbitrary
# log text that happens to contain "E404" etc.
if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
exit 1
fi
# Only tolerate the explicit "version already published" case.
# npm returns this as E403 with body "You cannot publish over..."
# or EPUBLISHCONFLICT depending on version.
if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
echo "[publish] some packages already at this version — continuing (non-fatal)"
exit 0
fi
echo "[publish] FATAL: publish failed with unrecognized error — failing pipeline" >&2
exit 1
depends_on: depends_on:
- build - build
@@ -74,12 +103,12 @@ steps:
- mkdir -p /kaniko/.docker - mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- | - |
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/mosaic-stack/gateway:sha-${CI_COMMIT_SHA:0:7}" DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/gateway:latest" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
fi fi
if [ -n "$CI_COMMIT_TAG" ]; then if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/gateway:$CI_COMMIT_TAG" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
fi fi
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS /kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
depends_on: depends_on:
@@ -99,12 +128,12 @@ steps:
- mkdir -p /kaniko/.docker - mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- | - |
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/mosaic-stack/web:sha-${CI_COMMIT_SHA:0:7}" DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/web:latest" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
fi fi
if [ -n "$CI_COMMIT_TAG" ]; then if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/web:$CI_COMMIT_TAG" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
fi fi
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS /kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
depends_on: depends_on:

View File

@@ -21,11 +21,11 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo
| `apps/web` | Next.js dashboard | React 19, Tailwind | | `apps/web` | Next.js dashboard | React 19, Tailwind |
| `packages/types` | Shared TypeScript contracts | class-validator | | `packages/types` | Shared TypeScript contracts | class-validator |
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres | | `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaic/db | | `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
| `packages/brain` | Data layer (PG-backed) | @mosaic/db | | `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
| `packages/queue` | Valkey task queue + MCP | ioredis | | `packages/queue` | Valkey task queue + MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaic/queue | | `packages/coord` | Mission coordination | @mosaicstack/queue |
| `packages/cli` | Unified CLI + Pi TUI | Ink, Pi SDK | | `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
| `plugins/discord` | Discord channel plugin | discord.js | | `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf | | `plugins/telegram` | Telegram channel plugin | Telegraf |
@@ -33,9 +33,9 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo
1. Gateway is the single API surface — all clients connect through it 1. Gateway is the single API surface — all clients connect through it
2. Pi SDK is ESM-only — gateway and CLI must use ESM 2. Pi SDK is ESM-only — gateway and CLI must use ESM
3. Socket.IO typed events defined in `@mosaic/types` enforce compile-time contracts 3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
4. OTEL auto-instrumentation loads before NestJS bootstrap 4. OTEL auto-instrumentation loads before NestJS bootstrap
5. BetterAuth manages auth tables; schema defined in `@mosaic/db` 5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686) 6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata) 7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
@@ -59,9 +59,9 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.** The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
| Value | When to use | Budget | | Value | When to use | Budget |
| -------- | ----------------------------------------------------------- | -------------------------- | | --------- | ----------------------------------------------------------- | -------------------------- |
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred | | `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits | | `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier | | `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota | | `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize | | `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |

View File

@@ -10,7 +10,7 @@ Self-hosted, multi-user AI agent platform. TypeScript monorepo.
- **Web**: Next.js 16 + React 19 (`apps/web`) - **Web**: Next.js 16 + React 19 (`apps/web`)
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`) - **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
- **Auth**: BetterAuth (`packages/auth`) - **Auth**: BetterAuth (`packages/auth`)
- **Agent**: Pi SDK (`packages/agent`, `packages/cli`) - **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
- **Queue**: Valkey 8 (`packages/queue`) - **Queue**: Valkey 8 (`packages/queue`)
- **Build**: pnpm workspaces + Turborepo - **Build**: pnpm workspaces + Turborepo
- **CI**: Woodpecker CI - **CI**: Woodpecker CI
@@ -26,13 +26,13 @@ pnpm test # Vitest (all packages)
pnpm build # Build all packages pnpm build # Build all packages
# Database # Database
pnpm --filter @mosaic/db db:push # Push schema to PG (dev) pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
pnpm --filter @mosaic/db db:generate # Generate migrations pnpm --filter @mosaicstack/db db:generate # Generate migrations
pnpm --filter @mosaic/db db:migrate # Run migrations pnpm --filter @mosaicstack/db db:migrate # Run migrations
# Dev # Dev
docker compose up -d # Start PG, Valkey, OTEL, Jaeger docker compose up -d # Start PG, Valkey, OTEL, Jaeger
pnpm --filter @mosaic/gateway exec tsx src/main.ts # Start gateway pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway
``` ```
## Conventions ## Conventions

150
README.md
View File

@@ -7,26 +7,39 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
## Quick Install ## Quick Install
```bash ```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh) curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
```bash
bash <(curl -fsSL …) --yes # Accept all defaults
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
``` ```
This installs both components: This installs both components:
| Component | What | Where | | Component | What | Where |
| --------------- | ----------------------------------------------------- | -------------------- | | ----------------------- | ---------------------------------------------------------------- | -------------------- |
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | | **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaic/cli** | TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | | **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
After install, set up your agent identity: After install, the wizard runs automatically or you can invoke it manually:
```bash ```bash
mosaic init # Interactive wizard mosaic wizard # Full guided setup (gateway install → verify)
``` ```
### Requirements ### Requirements
- Node.js ≥ 20 - Node.js ≥ 20
- npm (for global @mosaic/cli install) - npm (for global @mosaicstack/mosaic install)
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent) - One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
## Usage ## Usage
@@ -49,10 +62,34 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
```bash ```bash
mosaic tui # Interactive TUI connected to the gateway mosaic tui # Interactive TUI connected to the gateway
mosaic login # Authenticate with a gateway instance mosaic gateway login # Authenticate with a gateway instance
mosaic sessions list # List active agent sessions mosaic sessions list # List active agent sessions
``` ```
### Gateway Management
```bash
mosaic gateway install # Install and configure the gateway service
mosaic gateway verify # Post-install health check
mosaic gateway login # Authenticate and store a session token
mosaic gateway config rotate-token # Rotate your API token
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
```
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
### Configuration
Mosaic supports three storage tiers: `local` (PGlite, single-host), `standalone` (PostgreSQL, single-host), and `federated` (PostgreSQL + pgvector + Valkey, multi-host). See [Federated Tier Setup](docs/federation/SETUP.md) for multi-user and production deployments, or [Migrating to Federated](docs/guides/migrate-tier.md) to upgrade from existing tiers.
```bash
mosaic config show # Print full config as JSON
mosaic config get <key> # Read a specific key
mosaic config set <key> <val># Write a key
mosaic config edit # Open config in $EDITOR
mosaic config path # Print config file path
```
### Management ### Management
```bash ```bash
@@ -65,6 +102,80 @@ mosaic coord init # Initialize a new orchestration mission
mosaic prdy init # Create a PRD via guided session mosaic prdy init # Create a PRD via guided session
``` ```
### Sub-package Commands
Each Mosaic sub-package exposes its API surface through the unified CLI:
```bash
# User management
mosaic auth users list
mosaic auth users create
mosaic auth sso
# Agent brain (projects, missions, tasks)
mosaic brain projects
mosaic brain missions
mosaic brain tasks
mosaic brain conversations
# Agent forge pipeline
mosaic forge run
mosaic forge status
mosaic forge resume
mosaic forge personas
# Structured logging
mosaic log tail
mosaic log search
mosaic log export
mosaic log level
# MACP protocol
mosaic macp tasks
mosaic macp submit
mosaic macp gate
mosaic macp events
# Agent memory
mosaic memory search
mosaic memory stats
mosaic memory insights
mosaic memory preferences
# Task queue (Valkey)
mosaic queue list
mosaic queue stats
mosaic queue pause
mosaic queue resume
mosaic queue jobs
mosaic queue drain
# Object storage
mosaic storage status
mosaic storage tier
mosaic storage export
mosaic storage import
mosaic storage migrate
```
### Telemetry
```bash
# Local observability (OTEL / Jaeger)
mosaic telemetry local status
mosaic telemetry local tail
mosaic telemetry local jaeger
# Remote telemetry (dry-run by default)
mosaic telemetry status
mosaic telemetry opt-in
mosaic telemetry opt-out
mosaic telemetry test
mosaic telemetry upload # Dry-run unless opted in
```
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
## Development ## Development
### Prerequisites ### Prerequisites
@@ -76,8 +187,8 @@ mosaic prdy init # Create a PRD via guided session
### Setup ### Setup
```bash ```bash
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git git clone git@git.mosaicstack.dev:mosaicstack/stack.git
cd mosaic-stack cd stack
# Start infrastructure (Postgres, Valkey, Jaeger) # Start infrastructure (Postgres, Valkey, Jaeger)
docker compose up -d docker compose up -d
@@ -86,7 +197,7 @@ docker compose up -d
pnpm install pnpm install
# Run migrations # Run migrations
pnpm --filter @mosaic/db run db:migrate pnpm --filter @mosaicstack/db run db:migrate
# Start all services in dev mode # Start all services in dev mode
pnpm dev pnpm dev
@@ -126,13 +237,12 @@ npm packages are published to the Gitea package registry on main merges.
## Architecture ## Architecture
``` ```
mosaic-stack/ stack/
├── apps/ ├── apps/
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL) │ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
│ └── web/ Next.js dashboard (React 19, Tailwind) │ └── web/ Next.js dashboard (React 19, Tailwind)
├── packages/ ├── packages/
│ ├── cli/ Mosaic CLI — TUI, gateway client, wizard │ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
│ ├── mosaic/ Framework — wizard, runtime detection, update checker
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events) │ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
│ ├── db/ Drizzle ORM schema + migrations (pgvector) │ ├── db/ Drizzle ORM schema + migrations (pgvector)
│ ├── auth/ BetterAuth configuration │ ├── auth/ BetterAuth configuration
@@ -153,7 +263,7 @@ mosaic-stack/
│ ├── macp/ OpenClaw MACP runtime plugin │ ├── macp/ OpenClaw MACP runtime plugin
│ └── mosaic-framework/ OpenClaw framework injection plugin │ └── mosaic-framework/ OpenClaw framework injection plugin
├── tools/ ├── tools/
│ └── install.sh Unified installer (framework + npm CLI) │ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
├── scripts/agent/ Agent session lifecycle scripts ├── scripts/agent/ Agent session lifecycle scripts
├── docker-compose.yml Dev infrastructure ├── docker-compose.yml Dev infrastructure
└── .woodpecker/ CI pipeline configs └── .woodpecker/ CI pipeline configs
@@ -163,7 +273,7 @@ mosaic-stack/
- **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it - **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it
- **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution - **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution
- **Socket.IO typed events** — defined in `@mosaic/types`, enforced at compile time - **Socket.IO typed events** — defined in `@mosaicstack/types`, enforced at compile time
- **OTEL auto-instrumentation** — loads before NestJS bootstrap - **OTEL auto-instrumentation** — loads before NestJS bootstrap
- **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata - **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata
@@ -200,7 +310,13 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
Run the installer again — it handles upgrades automatically: Run the installer again — it handles upgrades automatically:
```bash ```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh) curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
``` ```
Or use the CLI: Or use the CLI:
@@ -219,6 +335,8 @@ bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework) bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
``` ```
## Contributing ## Contributing

View File

@@ -1,9 +1,9 @@
{ {
"name": "@mosaic/gateway", "name": "@mosaicstack/gateway",
"version": "0.1.0", "version": "0.0.6",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "apps/gateway" "directory": "apps/gateway"
}, },
"type": "module", "type": "module",
@@ -15,7 +15,7 @@
"dist" "dist"
], ],
"publishConfig": { "publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm/", "registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public" "access": "public"
}, },
"scripts": { "scripts": {
@@ -31,18 +31,18 @@
"@mariozechner/pi-ai": "^0.65.0", "@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0", "@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1", "@modelcontextprotocol/sdk": "^1.27.1",
"@mosaic/auth": "workspace:^", "@mosaicstack/auth": "workspace:^",
"@mosaic/brain": "workspace:^", "@mosaicstack/brain": "workspace:^",
"@mosaic/config": "workspace:^", "@mosaicstack/config": "workspace:^",
"@mosaic/coord": "workspace:^", "@mosaicstack/coord": "workspace:^",
"@mosaic/db": "workspace:^", "@mosaicstack/db": "workspace:^",
"@mosaic/discord-plugin": "workspace:^", "@mosaicstack/discord-plugin": "workspace:^",
"@mosaic/log": "workspace:^", "@mosaicstack/log": "workspace:^",
"@mosaic/memory": "workspace:^", "@mosaicstack/memory": "workspace:^",
"@mosaic/queue": "workspace:^", "@mosaicstack/queue": "workspace:^",
"@mosaic/storage": "workspace:^", "@mosaicstack/storage": "workspace:^",
"@mosaic/telegram-plugin": "workspace:^", "@mosaicstack/telegram-plugin": "workspace:^",
"@mosaic/types": "workspace:^", "@mosaicstack/types": "workspace:^",
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.0", "@nestjs/platform-fastify": "^11.0.0",
@@ -56,6 +56,7 @@
"@opentelemetry/sdk-metrics": "^2.6.0", "@opentelemetry/sdk-metrics": "^2.6.0",
"@opentelemetry/sdk-node": "^0.213.0", "@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/semantic-conventions": "^1.40.0", "@opentelemetry/semantic-conventions": "^1.40.0",
"@peculiar/x509": "^2.0.0",
"@sinclair/typebox": "^0.34.48", "@sinclair/typebox": "^0.34.48",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"bullmq": "^5.71.0", "bullmq": "^5.71.0",
@@ -63,8 +64,11 @@
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"ioredis": "^5.10.0",
"jose": "^6.2.2",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"openai": "^6.32.0", "openai": "^6.32.0",
"postgres": "^3.4.8",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"socket.io": "^4.8.0", "socket.io": "^4.8.0",
@@ -72,11 +76,17 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/testing": "^11.1.18",
"@swc/core": "^1.15.24",
"@swc/helpers": "^0.5.21",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"supertest": "^7.2.2",
"tsx": "^4.0.0", "tsx": "^4.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"unplugin-swc": "^1.5.9",
"vitest": "^2.0.0" "vitest": "^2.0.0"
} }
} }

View File

@@ -12,7 +12,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { ConversationHistoryMessage } from '../agent/agent.service.js'; import type { ConversationHistoryMessage } from '../agent/agent.service.js';
import { ConversationsController } from '../conversations/conversations.controller.js'; import { ConversationsController } from '../conversations/conversations.controller.js';
import type { Message } from '@mosaic/brain'; import type { Message } from '@mosaicstack/brain';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared test data // Shared test data

View File

@@ -18,13 +18,13 @@
*/ */
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createDb } from '@mosaic/db'; import { createDb } from '@mosaicstack/db';
import { createConversationsRepo } from '@mosaic/brain'; import { createConversationsRepo } from '@mosaicstack/brain';
import { createAgentsRepo } from '@mosaic/brain'; import { createAgentsRepo } from '@mosaicstack/brain';
import { createPreferencesRepo, createInsightsRepo } from '@mosaic/memory'; import { createPreferencesRepo, createInsightsRepo } from '@mosaicstack/memory';
import { users, conversations, messages, agents, preferences, insights } from '@mosaic/db'; import { users, conversations, messages, agents, preferences, insights } from '@mosaicstack/db';
import { eq } from '@mosaic/db'; import { eq } from '@mosaicstack/db';
import type { DbHandle } from '@mosaic/db'; import type { DbHandle } from '@mosaicstack/db';
// ─── Fixed IDs so the afterAll cleanup is deterministic ────────────────────── // ─── Fixed IDs so the afterAll cleanup is deterministic ──────────────────────

View File

@@ -0,0 +1,64 @@
/**
* Test B — Gateway boot refuses (fail-fast) when PG is unreachable.
*
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
* (Valkey must be running; only PG is intentionally misconfigured.)
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts
*
* Skipped when FEDERATED_INTEGRATION !== '1'.
*/
import net from 'node:net';
import { beforeAll, describe, expect, it } from 'vitest';
import { TierDetectionError, detectAndAssertTier } from '@mosaicstack/storage';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const VALKEY_URL = 'redis://localhost:6380';
/**
* Reserves a guaranteed-closed port at runtime by binding to an ephemeral OS
* port (port 0) and immediately releasing it. The OS will not reassign the
* port during the TIME_WAIT window, so it remains closed for the duration of
* this test.
*/
async function reserveClosedPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (typeof addr !== 'object' || !addr) return reject(new Error('no addr'));
const port = addr.port;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
describe.skipIf(!run)('federated boot — PG unreachable', () => {
let badPgUrl: string;
beforeAll(async () => {
const closedPort = await reserveClosedPort();
badPgUrl = `postgresql://mosaic:mosaic@localhost:${closedPort}/mosaic`;
});
it('detectAndAssertTier throws TierDetectionError with service: postgres when PG is down', async () => {
const brokenConfig = {
tier: 'federated' as const,
storage: {
type: 'postgres' as const,
url: badPgUrl,
enableVector: true,
},
queue: {
type: 'bullmq',
url: VALKEY_URL,
},
};
await expect(detectAndAssertTier(brokenConfig)).rejects.toSatisfy(
(err: unknown) => err instanceof TierDetectionError && err.service === 'postgres',
);
}, 10_000);
});

View File

@@ -0,0 +1,50 @@
/**
* Test A — Gateway boot succeeds when federated services are up.
*
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.success.integration.test.ts
*
* Skipped when FEDERATED_INTEGRATION !== '1'.
*/
import postgres from 'postgres';
import { afterAll, describe, expect, it } from 'vitest';
import { detectAndAssertTier } from '@mosaicstack/storage';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const VALKEY_URL = 'redis://localhost:6380';
const federatedConfig = {
tier: 'federated' as const,
storage: {
type: 'postgres' as const,
url: PG_URL,
enableVector: true,
},
queue: {
type: 'bullmq',
url: VALKEY_URL,
},
};
describe.skipIf(!run)('federated boot — success path', () => {
let sql: ReturnType<typeof postgres> | undefined;
afterAll(async () => {
if (sql) {
await sql.end({ timeout: 2 }).catch(() => {});
}
});
it('detectAndAssertTier resolves without throwing when federated services are up', async () => {
await expect(detectAndAssertTier(federatedConfig)).resolves.toBeUndefined();
}, 10_000);
it('pgvector extension is registered (pg_extension row exists)', async () => {
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
const rows = await sql`SELECT * FROM pg_extension WHERE extname = 'vector'`;
expect(rows).toHaveLength(1);
}, 10_000);
});

View File

@@ -0,0 +1,43 @@
/**
* Test C — pgvector extension is functional end-to-end.
*
* Creates a temp table with a vector(3) column, inserts a row, and queries it
* back — confirming the extension is not just registered but operational.
*
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-pgvector.integration.test.ts
*
* Skipped when FEDERATED_INTEGRATION !== '1'.
*/
import postgres from 'postgres';
import { afterAll, describe, expect, it } from 'vitest';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
let sql: ReturnType<typeof postgres> | undefined;
afterAll(async () => {
if (sql) {
await sql.end({ timeout: 2 }).catch(() => {});
}
});
describe.skipIf(!run)('federated pgvector — functional end-to-end', () => {
it('vector ops round-trip: INSERT [1,2,3] and SELECT returns [1,2,3]', async () => {
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
await sql`CREATE TEMP TABLE t (id int, embedding vector(3))`;
await sql`INSERT INTO t VALUES (1, '[1,2,3]')`;
const rows = await sql`SELECT embedding FROM t`;
expect(rows).toHaveLength(1);
// The postgres driver returns vector columns as strings like '[1,2,3]'.
// Normalise by parsing the string representation.
const raw = rows[0]?.['embedding'] as string;
const parsed = JSON.parse(raw) as number[];
expect(parsed).toEqual([1, 2, 3]);
}, 10_000);
});

View File

@@ -0,0 +1,243 @@
/**
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
*
* Covers MILESTONES.md acceptance test #6:
* "`peer add <url>` on Server A yields an `active` peer record with a valid cert + key"
*
* This test simulates two gateways using a single bootstrapped NestJS app:
* - "Server A": the admin API that generates a keypair and stores the cert
* - "Server B": the enrollment endpoint that signs the CSR
* Both share the same DB + Step-CA in the test environment.
*
* Prerequisites:
* docker compose -f docker-compose.federated.yml --profile federated up -d
*
* Run:
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
* STEP_CA_URL=https://localhost:9000 \
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
* pnpm --filter @mosaicstack/gateway test \
* src/__tests__/integration/federation-m2-e2e.integration.test.ts
*
* Obtaining Step-CA credentials:
* # Extract provisioner key from running container:
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
* # Copy root cert from container:
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
*
* Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set.
*/
import * as crypto from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test } from '@nestjs/testing';
import { ValidationPipe } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import supertest from 'supertest';
import {
createDb,
type Db,
type DbHandle,
federationPeers,
federationGrants,
federationEnrollmentTokens,
inArray,
eq,
} from '@mosaicstack/db';
import * as schema from '@mosaicstack/db';
import { DB } from '../../database/database.module.js';
import { AdminGuard } from '../../admin/admin.guard.js';
import { FederationModule } from '../../federation/federation.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { EnrollmentService } from '../../federation/enrollment.service.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const stepCaRun =
run &&
process.env['STEP_CA_AVAILABLE'] === '1' &&
!!process.env['STEP_CA_URL'] &&
!!process.env['STEP_CA_PROVISIONER_KEY_JSON'] &&
!!process.env['STEP_CA_ROOT_CERT_PATH'];
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = crypto.randomUUID();
describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => {
let handle: DbHandle;
let db: Db;
let app: NestFastifyApplication;
let agent: ReturnType<typeof supertest>;
let grantsService: GrantsService;
let enrollmentService: EnrollmentService;
const createdTokenGrantIds: string[] = [];
const createdGrantIds: string[] = [];
const createdPeerIds: string[] = [];
const createdUserIds: string[] = [];
beforeAll(async () => {
process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key';
handle = createDb(PG_URL);
db = handle.db;
const moduleRef = await Test.createTestingModule({
imports: [FederationModule],
providers: [{ provide: DB, useValue: db }],
})
.overrideGuard(AdminGuard)
.useValue({ canActivate: () => true })
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
await app.getHttpAdapter().getInstance().ready();
agent = supertest(app.getHttpServer());
grantsService = moduleRef.get(GrantsService);
enrollmentService = moduleRef.get(EnrollmentService);
}, 30_000);
afterAll(async () => {
if (db && createdTokenGrantIds.length > 0) {
await db
.delete(federationEnrollmentTokens)
.where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (db && createdGrantIds.length > 0) {
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (db && createdPeerIds.length > 0) {
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, createdPeerIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (db && createdUserIds.length > 0) {
await db
.delete(schema.users)
.where(inArray(schema.users.id, createdUserIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (app)
await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
if (handle)
await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
});
// -------------------------------------------------------------------------
// #6 — peer add: keypair → enrollment → cert storage → active peer record
// -------------------------------------------------------------------------
it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => {
// Create a subject user to satisfy FK on federation_grants.subject_user_id
const userId = crypto.randomUUID();
await db
.insert(schema.users)
.values({
id: userId,
name: `e2e-user-${RUN_ID}`,
email: `e2e-${RUN_ID}@federation-test.invalid`,
emailVerified: false,
})
.onConflictDoNothing();
createdUserIds.push(userId);
// ── Step A: "Server B" setup ─────────────────────────────────────────
// Server B admin creates a grant and generates an enrollment token to
// share out-of-band with Server A's operator.
// Insert a placeholder peer on "Server B" to satisfy the grant FK
const serverBPeerId = crypto.randomUUID();
await db
.insert(federationPeers)
.values({
id: serverBPeerId,
commonName: `server-b-peer-${RUN_ID}`,
displayName: 'Server B Placeholder',
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `serial-b-${serverBPeerId}`,
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
state: 'pending',
})
.onConflictDoNothing();
createdPeerIds.push(serverBPeerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
peerId: serverBPeerId,
});
createdGrantIds.push(grant.id);
createdTokenGrantIds.push(grant.id);
const { token } = await enrollmentService.createToken({
grantId: grant.id,
peerId: serverBPeerId,
ttlSeconds: 900,
});
// ── Step B: "Server A" generates keypair ─────────────────────────────
const keypairRes = await agent
.post('/api/admin/federation/peers/keypair')
.send({
commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`,
displayName: 'E2E Test Peer',
endpointUrl: 'https://test.invalid',
})
.set('Content-Type', 'application/json');
expect(keypairRes.status).toBe(201);
const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string };
expect(typeof peerId).toBe('string');
expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----');
createdPeerIds.push(peerId);
// ── Step C: Enrollment (simulates Server A sending CSR to Server B) ──
const enrollRes = await agent
.post(`/api/federation/enrollment/${token}`)
.send({ csrPem })
.set('Content-Type', 'application/json');
expect(enrollRes.status).toBe(200);
const { certPem, certChainPem } = enrollRes.body as {
certPem: string;
certChainPem: string;
};
expect(certPem).toContain('-----BEGIN CERTIFICATE-----');
expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----');
// ── Step D: "Server A" stores the cert ───────────────────────────────
const storeRes = await agent
.patch(`/api/admin/federation/peers/${peerId}/cert`)
.send({ certPem })
.set('Content-Type', 'application/json');
expect(storeRes.status).toBe(200);
// ── Step E: Verify peer record in DB ─────────────────────────────────
const [peer] = await db
.select()
.from(federationPeers)
.where(eq(federationPeers.id, peerId))
.limit(1);
expect(peer).toBeDefined();
expect(peer?.state).toBe('active');
expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----');
expect(typeof peer?.certSerial).toBe('string');
expect((peer?.certSerial ?? '').length).toBeGreaterThan(0);
// clientKeyPem is a sealed ciphertext — must not be a raw PEM
expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
// certNotAfter must be in the future
expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now());
}, 60_000);
});

View File

@@ -0,0 +1,483 @@
/**
* Federation M2 integration tests (FED-M2-09).
*
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
*
* Prerequisites:
* docker compose -f docker-compose.federated.yml --profile federated up -d
*
* Run DB-only tests (no Step-CA):
* FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
* src/__tests__/integration/federation-m2.integration.test.ts
*
* Run all tests including Step-CA-dependent ones:
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
* STEP_CA_URL=https://localhost:9000 \
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
* pnpm --filter @mosaicstack/gateway test \
* src/__tests__/integration/federation-m2.integration.test.ts
*
* Obtaining Step-CA credentials:
* # Extract provisioner key from running container:
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
* # Copy root cert from container:
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
*/
import * as crypto from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test } from '@nestjs/testing';
import { GoneException } from '@nestjs/common';
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
import {
createDb,
type Db,
type DbHandle,
federationPeers,
federationGrants,
federationEnrollmentTokens,
inArray,
eq,
} from '@mosaicstack/db';
import * as schema from '@mosaicstack/db';
import { seal } from '@mosaicstack/auth';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { EnrollmentService } from '../../federation/enrollment.service.js';
import { CaService } from '../../federation/ca.service.js';
import { FederationScopeError } from '../../federation/scope-schema.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
// ---------------------------------------------------------------------------
// Helpers for test data isolation
// ---------------------------------------------------------------------------
/** Unique run prefix to identify rows created by this test run. */
const RUN_ID = crypto.randomUUID();
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
async function insertTestUser(db: Db, id: string): Promise<void> {
await db
.insert(schema.users)
.values({
id,
name: `test-user-${id}`,
email: `test-${id}@federation-test.invalid`,
emailVerified: false,
})
.onConflictDoNothing();
}
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
await db
.insert(federationPeers)
.values({
id,
commonName: `test-peer-${RUN_ID}-${suffix}`,
displayName: `Test Peer ${suffix}`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `test-serial-${id}`,
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
state: 'pending',
})
.onConflictDoNothing();
}
// ---------------------------------------------------------------------------
// DB-only test module (CaService mocked so env vars not required)
// ---------------------------------------------------------------------------
function buildDbModule(db: Db) {
return Test.createTestingModule({
providers: [
{ provide: DB, useValue: db },
GrantsService,
{
provide: CaService,
useValue: {
issueCert: async () => {
throw new Error('CaService.issueCert should not be called in DB-only tests');
},
},
},
EnrollmentService,
],
}).compile();
}
// ---------------------------------------------------------------------------
// Test suite — DB-only (no Step-CA)
// ---------------------------------------------------------------------------
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
let handle: DbHandle;
let db: Db;
let grantsService: GrantsService;
/** IDs created during this run — cleaned up in afterAll. */
const createdGrantIds: string[] = [];
const createdPeerIds: string[] = [];
const createdUserIds: string[] = [];
beforeAll(async () => {
process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';
handle = createDb(PG_URL);
db = handle.db;
const moduleRef = await buildDbModule(db);
grantsService = moduleRef.get(GrantsService);
});
afterAll(async () => {
// Clean up in FK-safe order: tokens → grants → peers → users
if (db && createdGrantIds.length > 0) {
await db
.delete(federationEnrollmentTokens)
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdPeerIds.length > 0) {
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, createdPeerIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdUserIds.length > 0) {
await db
.delete(schema.users)
.where(inArray(schema.users.id, createdUserIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (handle)
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
});
// -------------------------------------------------------------------------
// #1 — grant create writes a pending row
// -------------------------------------------------------------------------
it('#1 — createGrant writes a pending row to DB', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 100,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'test1');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
createdGrantIds.push(grant.id);
// Verify the row exists in DB with correct shape
const [row] = await db
.select()
.from(federationGrants)
.where(eq(federationGrants.id, grant.id))
.limit(1);
expect(row).toBeDefined();
expect(row?.status).toBe('pending');
expect(row?.peerId).toBe(peerId);
expect(row?.subjectUserId).toBe(userId);
const storedScope = row?.scope as Record<string, unknown>;
expect(storedScope['resources']).toEqual(['tasks']);
expect(storedScope['max_rows_per_query']).toBe(100);
}, 15_000);
// -------------------------------------------------------------------------
// #7 — scope with unknown resource type rejected
// -------------------------------------------------------------------------
it('#7 — createGrant rejects scope with unknown resource type', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const invalidScope = {
resources: ['totally_unknown_resource'],
excluded_resources: [],
max_rows_per_query: 100,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'test7');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
await expect(
grantsService.createGrant({
subjectUserId: userId,
scope: invalidScope,
peerId,
}),
).rejects.toThrow(FederationScopeError);
}, 15_000);
// -------------------------------------------------------------------------
// #8 — listGrants returns accurate status for grants in various states
// -------------------------------------------------------------------------
it('#8 — listGrants returns accurate status for grants in various states', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['notes'],
excluded_resources: [],
max_rows_per_query: 50,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'test8');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
// Create two pending grants via GrantsService
const grantA = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
const grantB = await grantsService.createGrant({
subjectUserId: userId,
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
peerId,
});
createdGrantIds.push(grantA.id, grantB.id);
// Insert a third grant directly in 'revoked' state to test status variety
const [grantC] = await db
.insert(federationGrants)
.values({
id: crypto.randomUUID(),
subjectUserId: userId,
peerId,
scope: validScope,
status: 'revoked',
revokedAt: new Date(),
})
.returning();
createdGrantIds.push(grantC!.id);
// List all grants for this peer
const allForPeer = await grantsService.listGrants({ peerId });
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
expect(ourGrants).toHaveLength(3);
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
expect(pendingGrants).toHaveLength(2);
expect(revokedGrants).toHaveLength(1);
// Status-filtered query
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
// Verify peer list from DB also shows the peer rows with correct state
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
expect(peers).toHaveLength(1);
expect(peers[0]?.state).toBe('pending');
}, 15_000);
// -------------------------------------------------------------------------
// #5 — client_key_pem encrypted at rest
// -------------------------------------------------------------------------
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
const peerId = crypto.randomUUID();
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
const sealed = seal(rawPem);
await db.insert(federationPeers).values({
id: peerId,
commonName: `test-peer-${RUN_ID}-sealed`,
displayName: 'Sealed Key Test Peer',
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `test-serial-sealed-${peerId}`,
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
state: 'pending',
clientKeyPem: sealed,
});
createdPeerIds.push(peerId);
const [row] = await db
.select()
.from(federationPeers)
.where(eq(federationPeers.id, peerId))
.limit(1);
expect(row).toBeDefined();
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
expect(row?.clientKeyPem).toBeDefined();
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
// The sealed value should be non-trivial (at least 20 chars)
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
}, 15_000);
});
// ---------------------------------------------------------------------------
// Test suite — Step-CA gated
// ---------------------------------------------------------------------------
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
let handle: DbHandle;
let db: Db;
let grantsService: GrantsService;
let enrollmentService: EnrollmentService;
const createdGrantIds: string[] = [];
const createdPeerIds: string[] = [];
const createdUserIds: string[] = [];
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
const moduleRef = await Test.createTestingModule({
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
}).compile();
grantsService = moduleRef.get(GrantsService);
enrollmentService = moduleRef.get(EnrollmentService);
});
afterAll(async () => {
if (db && createdGrantIds.length > 0) {
await db
.delete(federationEnrollmentTokens)
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdPeerIds.length > 0) {
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, createdPeerIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdUserIds.length > 0) {
await db
.delete(schema.users)
.where(inArray(schema.users.id, createdUserIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (handle)
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
});
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
async function generateCsrPem(cn: string): Promise<string> {
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
const csr = await Pkcs10CertificateRequestGenerator.create({
name: `CN=${cn}`,
keys: keyPair,
signingAlgorithm: alg,
});
return csr.toString('pem');
}
// -------------------------------------------------------------------------
// #2 — enrollment signs CSR and returns cert
// -------------------------------------------------------------------------
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 100,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'ca-test2');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
createdGrantIds.push(grant.id);
const { token } = await enrollmentService.createToken({
grantId: grant.id,
peerId,
ttlSeconds: 900,
});
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
const result = await enrollmentService.redeem(token, csrPem);
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
// Verify the issued cert parses cleanly
const cert = new PeculiarX509(result.certPem);
expect(cert.serialNumber).toBeTruthy();
}, 30_000);
// -------------------------------------------------------------------------
// #3 — token single-use; second attempt returns GoneException
// -------------------------------------------------------------------------
it('#3 — second redeem of the same token throws GoneException', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['notes'],
excluded_resources: [],
max_rows_per_query: 50,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'ca-test3');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
createdGrantIds.push(grant.id);
const { token } = await enrollmentService.createToken({
grantId: grant.id,
peerId,
ttlSeconds: 900,
});
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
// First redeem must succeed
const result = await enrollmentService.redeem(token, csrPem);
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
// Second redeem with the same token must be rejected
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
}, 30_000);
});

View File

@@ -1,6 +1,6 @@
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaic/db'; import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaic/queue'; import { createQueue } from '@mosaicstack/queue';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js'; import { ProviderService } from '../agent/provider.service.js';

View File

@@ -11,7 +11,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto'; import { randomBytes, createHash } from 'node:crypto';
import { eq, type Db, adminTokens } from '@mosaic/db'; import { eq, type Db, adminTokens } from '@mosaicstack/db';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { AdminGuard } from './admin.guard.js'; import { AdminGuard } from './admin.guard.js';

View File

@@ -13,8 +13,8 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { eq, type Db, users as usersTable } from '@mosaic/db'; import { eq, type Db, users as usersTable } from '@mosaicstack/db';
import type { Auth } from '@mosaic/auth'; import type { Auth } from '@mosaicstack/auth';
import { AUTH } from '../auth/auth.tokens.js'; import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { AdminGuard } from './admin.guard.js'; import { AdminGuard } from './admin.guard.js';

View File

@@ -8,9 +8,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { fromNodeHeaders } from 'better-auth/node'; import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaic/auth'; import type { Auth } from '@mosaicstack/auth';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaicstack/db';
import { eq, adminTokens, users as usersTable } from '@mosaic/db'; import { eq, adminTokens, users as usersTable } from '@mosaicstack/db';
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
import { AUTH } from '../auth/auth.tokens.js'; import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';

View File

@@ -8,12 +8,13 @@ import {
Post, Post,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto'; import { randomBytes, createHash } from 'node:crypto';
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaic/db'; import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
import type { Auth } from '@mosaic/auth'; import type { Auth } from '@mosaicstack/auth';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { AUTH } from '../auth/auth.tokens.js'; import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js'; import { BootstrapSetupDto } from './bootstrap.dto.js';
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
@Controller('api/bootstrap') @Controller('api/bootstrap')
export class BootstrapController { export class BootstrapController {

View File

@@ -0,0 +1,190 @@
/**
* E2E integration test — POST /api/bootstrap/setup
*
* Regression guard for the `import type { BootstrapSetupDto }` class-erasure
* bug (IUV-M01, issue #436).
*
* When `BootstrapSetupDto` is imported with `import type`, TypeScript erases
* the class at compile time. NestJS then sees `Object` as the `@Body()`
* metatype, and ValidationPipe with `whitelist:true + forbidNonWhitelisted:true`
* treats every property as non-whitelisted, returning:
*
* 400 { message: ["property email should not exist", "property password should not exist"] }
*
* The fix is a plain value import (`import { BootstrapSetupDto }`), which
* preserves the class reference so Nest can read the class-validator decorators.
*
* This test MUST fail if `import type` is re-introduced on `BootstrapSetupDto`.
* A controller unit test that constructs ValidationPipe manually won't catch
* this — only the real DI binding path exercises the metatype lookup.
*/
import 'reflect-metadata';
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { Test } from '@nestjs/testing';
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { BootstrapController } from './bootstrap.controller.js';
import type { BootstrapResultDto } from './bootstrap.dto.js';
// ─── Minimal mock dependencies ───────────────────────────────────────────────
/**
* We use explicit `@Inject(AUTH)` / `@Inject(DB)` in the controller so we
* can provide mock values by token without spinning up the real DB or Auth.
*/
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
const MOCK_USER_ID = 'mock-user-id-001';
const mockAuth = {
api: {
createUser: () =>
Promise.resolve({
user: {
id: MOCK_USER_ID,
name: 'Admin',
email: 'admin@example.com',
},
}),
},
};
// Override db.select() so the second query (verify user exists) returns a user.
// The bootstrap controller calls select().from() twice:
// 1. count() to check zero users → returns [{total: 0}]
// 2. select().where().limit() → returns [the created user]
let selectCallCount = 0;
const mockDbWithUser = {
select: () => {
selectCallCount++;
return {
from: () => {
if (selectCallCount === 1) {
// First call: count — zero users
return Promise.resolve([{ total: 0 }]);
}
// Subsequent calls: return a mock user row
return {
where: () => ({
limit: () =>
Promise.resolve([
{
id: MOCK_USER_ID,
name: 'Admin',
email: 'admin@example.com',
role: 'admin',
},
]),
}),
};
},
};
},
update: () => ({
set: () => ({
where: () => Promise.resolve([]),
}),
}),
insert: () => ({
values: () => ({
returning: () =>
Promise.resolve([
{
id: 'token-id-001',
label: 'Initial setup token',
},
]),
}),
}),
};
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
let app: INestApplication;
beforeAll(async () => {
selectCallCount = 0;
const moduleRef = await Test.createTestingModule({
controllers: [BootstrapController],
providers: [
{ provide: AUTH, useValue: mockAuth },
{ provide: DB, useValue: mockDbWithUser },
],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
// Mirror main.ts configuration exactly — this is what reproduced the 400.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
// Fastify requires waiting for the adapter to be ready
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
});
it('returns 201 (not 400) when a valid {name, email, password} body is sent', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: 'admin@example.com', password: 'password123' })
.set('Content-Type', 'application/json');
// Before the fix (import type), Nest ValidationPipe returned 400 with
// "property email should not exist" / "property password should not exist"
// because the DTO class was erased and every field looked non-whitelisted.
expect(res.status).not.toBe(400);
expect(res.status).toBe(201);
const body = res.body as BootstrapResultDto;
expect(body.user).toBeDefined();
expect(body.user.email).toBe('admin@example.com');
expect(body.token).toBeDefined();
expect(body.token.plaintext).toBeDefined();
});
it('returns 400 when extra forbidden properties are sent', async () => {
// This proves ValidationPipe IS active and working (forbidNonWhitelisted).
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({
name: 'Admin',
email: 'admin@example.com',
password: 'password123',
extraField: 'should-be-rejected',
})
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
});
it('returns 400 when email is invalid', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: 'not-an-email', password: 'password123' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
});
it('returns 400 when password is too short', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: 'admin@example.com', password: 'short' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RoutingService } from '../routing.service.js'; import { RoutingService } from '../routing.service.js';
import type { ModelInfo } from '@mosaic/types'; import type { ModelInfo } from '@mosaicstack/types';
const mockModels: ModelInfo[] = [ const mockModels: ModelInfo[] = [
{ {

View File

@@ -7,7 +7,7 @@ import type {
IProviderAdapter, IProviderAdapter,
ModelInfo, ModelInfo,
ProviderHealth, ProviderHealth,
} from '@mosaic/types'; } from '@mosaicstack/types';
/** /**
* Anthropic provider adapter. * Anthropic provider adapter.

View File

@@ -6,7 +6,7 @@ import type {
IProviderAdapter, IProviderAdapter,
ModelInfo, ModelInfo,
ProviderHealth, ProviderHealth,
} from '@mosaic/types'; } from '@mosaicstack/types';
/** Embedding models that Ollama ships with out of the box */ /** Embedding models that Ollama ships with out of the box */
const OLLAMA_EMBEDDING_MODELS: ReadonlyArray<{ const OLLAMA_EMBEDDING_MODELS: ReadonlyArray<{

View File

@@ -7,7 +7,7 @@ import type {
IProviderAdapter, IProviderAdapter,
ModelInfo, ModelInfo,
ProviderHealth, ProviderHealth,
} from '@mosaic/types'; } from '@mosaicstack/types';
/** /**
* OpenAI provider adapter. * OpenAI provider adapter.

View File

@@ -6,7 +6,7 @@ import type {
IProviderAdapter, IProviderAdapter,
ModelInfo, ModelInfo,
ProviderHealth, ProviderHealth,
} from '@mosaic/types'; } from '@mosaicstack/types';
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';

View File

@@ -6,7 +6,7 @@ import type {
IProviderAdapter, IProviderAdapter,
ModelInfo, ModelInfo,
ProviderHealth, ProviderHealth,
} from '@mosaic/types'; } from '@mosaicstack/types';
import { getModelCapability } from '../model-capabilities.js'; import { getModelCapability } from '../model-capabilities.js';
/** /**

View File

@@ -13,7 +13,7 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';

View File

@@ -7,8 +7,8 @@ import {
type AgentSessionEvent, type AgentSessionEvent,
type ToolDefinition, type ToolDefinition,
} from '@mariozechner/pi-coding-agent'; } from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import type { Memory } from '@mosaic/memory'; import type { Memory } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js';
import { EmbeddingService } from '../memory/embedding.service.js'; import { EmbeddingService } from '../memory/embedding.service.js';

View File

@@ -1,4 +1,4 @@
import type { ModelCapability } from '@mosaic/types'; import type { ModelCapability } from '@mosaicstack/types';
/** /**
* Comprehensive capability matrix for all target models. * Comprehensive capability matrix for all target models.

View File

@@ -1,62 +1,10 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import { seal, unseal } from '@mosaicstack/auth';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaicstack/db';
import { providerCredentials, eq, and } from '@mosaic/db'; import { providerCredentials, eq, and } from '@mosaicstack/db';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js'; import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV for GCM
const TAG_LENGTH = 16; // 128-bit auth tag
/**
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
* The secret is assumed to be set in the environment.
*/
function deriveEncryptionKey(): Buffer {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
}
return createHash('sha256').update(secret).digest();
}
/**
* Encrypt a plain-text value using AES-256-GCM.
* Output format: base64(iv + authTag + ciphertext)
*/
function encrypt(plaintext: string): string {
const key = deriveEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// Combine iv (12) + authTag (16) + ciphertext and base64-encode
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
}
/**
* Decrypt a value encrypted by `encrypt()`.
* Throws on authentication failure (tampered data).
*/
function decrypt(encoded: string): string {
const key = deriveEncryptionKey();
const combined = Buffer.from(encoded, 'base64');
const iv = combined.subarray(0, IV_LENGTH);
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf8');
}
@Injectable() @Injectable()
export class ProviderCredentialsService { export class ProviderCredentialsService {
private readonly logger = new Logger(ProviderCredentialsService.name); private readonly logger = new Logger(ProviderCredentialsService.name);
@@ -74,7 +22,7 @@ export class ProviderCredentialsService {
value: string, value: string,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const encryptedValue = encrypt(value); const encryptedValue = seal(value);
await this.db await this.db
.insert(providerCredentials) .insert(providerCredentials)
@@ -122,7 +70,7 @@ export class ProviderCredentialsService {
} }
try { try {
return decrypt(row.encryptedValue); return unseal(row.encryptedValue);
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Failed to decrypt credential for user=${userId} provider=${provider}`, `Failed to decrypt credential for user=${userId} provider=${provider}`,

View File

@@ -14,7 +14,7 @@ import type {
ModelInfo, ModelInfo,
ProviderHealth, ProviderHealth,
ProviderInfo, ProviderInfo,
} from '@mosaic/types'; } from '@mosaicstack/types';
import { import {
AnthropicAdapter, AnthropicAdapter,
OllamaAdapter, OllamaAdapter,

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Inject, Param, Post, UseGuards } from '@nestjs/common';
import type { RoutingCriteria } from '@mosaic/types'; import type { RoutingCriteria } from '@mosaicstack/types';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';
import { ProviderService } from './provider.service.js'; import { ProviderService } from './provider.service.js';

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ModelInfo } from '@mosaic/types'; import type { ModelInfo } from '@mosaicstack/types';
import type { RoutingCriteria, RoutingResult, CostTier } from '@mosaic/types'; import type { RoutingCriteria, RoutingResult, CostTier } from '@mosaicstack/types';
import { ProviderService } from './provider.service.js'; import { ProviderService } from './provider.service.js';
/** Per-million-token cost thresholds for tier classification */ /** Per-million-token cost thresholds for tier classification */

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common'; import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { routingRules, type Db, sql } from '@mosaic/db'; import { routingRules, type Db, sql } from '@mosaicstack/db';
import { DB } from '../../database/database.module.js'; import { DB } from '../../database/database.module.js';
import type { RoutingCondition, RoutingAction } from './routing.types.js'; import type { RoutingCondition, RoutingAction } from './routing.types.js';

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { routingRules, type Db, and, asc, eq, or } from '@mosaic/db'; import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
import { DB } from '../../database/database.module.js'; import { DB } from '../../database/database.module.js';
import { ProviderService } from '../provider.service.js'; import { ProviderService } from '../provider.service.js';
import { classifyTask } from './task-classifier.js'; import { classifyTask } from './task-classifier.js';

View File

@@ -13,7 +13,7 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { routingRules, type Db, and, asc, eq, or, inArray } from '@mosaic/db'; import { routingRules, type Db, and, asc, eq, or, inArray } from '@mosaicstack/db';
import { DB } from '../../database/database.module.js'; import { DB } from '../../database/database.module.js';
import { AuthGuard } from '../../auth/auth.guard.js'; import { AuthGuard } from '../../auth/auth.guard.js';
import { CurrentUser } from '../../auth/current-user.decorator.js'; import { CurrentUser } from '../../auth/current-user.decorator.js';

View File

@@ -1,7 +1,7 @@
/** /**
* Routing engine types — M4-002 (condition types) and M4-003 (action types). * Routing engine types — M4-002 (condition types) and M4-003 (action types).
* *
* These types are re-exported from `@mosaic/types` for shared use across packages. * These types are re-exported from `@mosaicstack/types` for shared use across packages.
*/ */
// ─── Classification primitives ─────────────────────────────────────────────── // ─── Classification primitives ───────────────────────────────────────────────
@@ -23,7 +23,7 @@ export type Domain = 'frontend' | 'backend' | 'devops' | 'docs' | 'general';
/** /**
* Cost tier for model selection. * Cost tier for model selection.
* Extends the existing `CostTier` in `@mosaic/types` with `local` for self-hosted models. * Extends the existing `CostTier` in `@mosaicstack/types` with `local` for self-hosted models.
*/ */
export type CostTier = 'cheap' | 'standard' | 'premium' | 'local'; export type CostTier = 'cheap' | 'standard' | 'premium' | 'local';

View File

@@ -1,6 +1,6 @@
import { Type } from '@sinclair/typebox'; import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
export function createBrainTools(brain: Brain): ToolDefinition[] { export function createBrainTools(brain: Brain): ToolDefinition[] {
const listProjects: ToolDefinition = { const listProjects: ToolDefinition = {

View File

@@ -1,7 +1,7 @@
import { Type } from '@sinclair/typebox'; import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { Memory } from '@mosaic/memory'; import type { Memory } from '@mosaicstack/memory';
import type { EmbeddingProvider } from '@mosaic/memory'; import type { EmbeddingProvider } from '@mosaicstack/memory';
/** /**
* Create memory tools bound to the session's authenticated userId. * Create memory tools bound to the session's authenticated userId.

View File

@@ -24,6 +24,7 @@ import { GCModule } from './gc/gc.module.js';
import { ReloadModule } from './reload/reload.module.js'; import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js'; import { WorkspaceModule } from './workspace/workspace.module.js';
import { QueueModule } from './queue/queue.module.js'; import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@Module({ @Module({
@@ -52,6 +53,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
QueueModule, QueueModule,
ReloadModule, ReloadModule,
WorkspaceModule, WorkspaceModule,
FederationModule,
], ],
controllers: [HealthController], controllers: [HealthController],
providers: [ providers: [

View File

@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http';
import { toNodeHandler } from 'better-auth/node'; import { toNodeHandler } from 'better-auth/node';
import type { Auth } from '@mosaic/auth'; import type { Auth } from '@mosaicstack/auth';
import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { AUTH } from './auth.tokens.js'; import { AUTH } from './auth.tokens.js';

View File

@@ -6,7 +6,7 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { fromNodeHeaders } from 'better-auth/node'; import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaic/auth'; import type { Auth } from '@mosaicstack/auth';
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
import { AUTH } from './auth.tokens.js'; import { AUTH } from './auth.tokens.js';

View File

@@ -1,6 +1,6 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { createAuth, type Auth } from '@mosaic/auth'; import { createAuth, type Auth } from '@mosaicstack/auth';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaicstack/db';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { AUTH } from './auth.tokens.js'; import { AUTH } from './auth.tokens.js';
import { SsoController } from './sso.controller.js'; import { SsoController } from './sso.controller.js';

View File

@@ -1,5 +1,5 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { buildSsoDiscovery, type SsoProviderDiscovery } from '@mosaic/auth'; import { buildSsoDiscovery, type SsoProviderDiscovery } from '@mosaicstack/auth';
@Controller('api/sso/providers') @Controller('api/sso/providers')
export class SsoController { export class SsoController {

View File

@@ -1,6 +1,6 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { createBrain, type Brain } from '@mosaic/brain'; import { createBrain, type Brain } from '@mosaicstack/brain';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaicstack/db';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { BRAIN } from './brain.tokens.js'; import { BRAIN } from './brain.tokens.js';

View File

@@ -11,15 +11,15 @@ import {
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import type { Auth } from '@mosaic/auth'; import type { Auth } from '@mosaicstack/auth';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import type { import type {
SetThinkingPayload, SetThinkingPayload,
SlashCommandPayload, SlashCommandPayload,
SystemReloadPayload, SystemReloadPayload,
RoutingDecisionInfo, RoutingDecisionInfo,
AbortPayload, AbortPayload,
} from '@mosaic/types'; } from '@mosaicstack/types';
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
import { AUTH } from '../auth/auth.tokens.js'; import { AUTH } from '../auth/auth.tokens.js';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CommandExecutorService } from './command-executor.service.js'; import { CommandExecutorService } from './command-executor.service.js';
import type { SlashCommandPayload } from '@mosaic/types'; import type { SlashCommandPayload } from '@mosaicstack/types';
// Minimal mock implementations // Minimal mock implementations
const mockRegistry = { const mockRegistry = {

View File

@@ -1,7 +1,7 @@
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common'; import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
import type { QueueHandle } from '@mosaic/queue'; import type { QueueHandle } from '@mosaicstack/queue';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types'; import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import { ChatGateway } from '../chat/chat.gateway.js'; import { ChatGateway } from '../chat/chat.gateway.js';
import { SessionGCService } from '../gc/session-gc.service.js'; import { SessionGCService } from '../gc/session-gc.service.js';

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { CommandRegistryService } from './command-registry.service.js'; import { CommandRegistryService } from './command-registry.service.js';
import type { CommandDef } from '@mosaic/types'; import type { CommandDef } from '@mosaicstack/types';
const mockCmd: CommandDef = { const mockCmd: CommandDef = {
name: 'test', name: 'test',

View File

@@ -1,5 +1,5 @@
import { Injectable, type OnModuleInit } from '@nestjs/common'; import { Injectable, type OnModuleInit } from '@nestjs/common';
import type { CommandDef, CommandManifest } from '@mosaic/types'; import type { CommandDef, CommandManifest } from '@mosaicstack/types';
@Injectable() @Injectable()
export class CommandRegistryService implements OnModuleInit { export class CommandRegistryService implements OnModuleInit {

View File

@@ -13,7 +13,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CommandRegistryService } from './command-registry.service.js'; import { CommandRegistryService } from './command-registry.service.js';
import { CommandExecutorService } from './command-executor.service.js'; import { CommandExecutorService } from './command-executor.service.js';
import type { SlashCommandPayload } from '@mosaic/types'; import type { SlashCommandPayload } from '@mosaicstack/types';
// ─── Mocks ─────────────────────────────────────────────────────────────────── // ─── Mocks ───────────────────────────────────────────────────────────────────

View File

@@ -1,5 +1,5 @@
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common'; import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaic/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import { ChatModule } from '../chat/chat.module.js'; import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js'; import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js'; import { ReloadModule } from '../reload/reload.module.js';

View File

@@ -1,5 +1,5 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { loadConfig, type MosaicConfig } from '@mosaic/config'; import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG'; export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';

View File

@@ -15,7 +15,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';

View File

@@ -8,7 +8,7 @@ import {
type MissionStatusSummary, type MissionStatusSummary,
type MissionTask, type MissionTask,
type TaskDetail, type TaskDetail,
} from '@mosaic/coord'; } from '@mosaicstack/coord';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import path from 'node:path'; import path from 'node:path';

View File

@@ -2,9 +2,9 @@ import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common'; import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createDb, createPgliteDb, type Db, type DbHandle } from '@mosaic/db'; import { createDb, createPgliteDb, type Db, type DbHandle } from '@mosaicstack/db';
import { createStorageAdapter, type StorageAdapter } from '@mosaic/storage'; import { createStorageAdapter, type StorageAdapter } from '@mosaicstack/storage';
import type { MosaicConfig } from '@mosaic/config'; import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js'; import { MOSAIC_CONFIG } from '../config/config.module.js';
export const DB_HANDLE = 'DB_HANDLE'; export const DB_HANDLE = 'DB_HANDLE';

View File

@@ -0,0 +1,373 @@
/**
* Unit tests for EnrollmentService — federation enrollment token flow (FED-M2-07).
*
* Coverage:
* createToken:
* - inserts token row with correct grantId, peerId, and future expiresAt
* - returns { token, expiresAt } with a 64-char hex token
* - clamps ttlSeconds to 900
*
* redeem — error paths:
* - NotFoundException when token row not found
* - GoneException when token already used (usedAt set)
* - GoneException when token expired (expiresAt < now)
* - GoneException when grant status is not pending
*
* redeem — success path:
* - atomically claims token BEFORE cert issuance (claim → issueCert → tx)
* - calls CaService.issueCert with correct args
* - activates grant + updates peer + writes audit log inside a transaction
* - returns { certPem, certChainPem }
*
* redeem — replay protection:
* - GoneException when claim UPDATE returns empty array (concurrent request won)
*/
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GoneException, NotFoundException } from '@nestjs/common';
import type { Db } from '@mosaicstack/db';
import { EnrollmentService } from '../enrollment.service.js';
// ---------------------------------------------------------------------------
// Test constants
// ---------------------------------------------------------------------------
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
const TOKEN = 'a'.repeat(64); // 64-char hex
const MOCK_CERT_PEM = '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n';
const MOCK_CHAIN_PEM = MOCK_CERT_PEM + MOCK_CERT_PEM;
const MOCK_SERIAL = 'ABCD1234';
// ---------------------------------------------------------------------------
// Factory helpers
// ---------------------------------------------------------------------------
function makeTokenRow(overrides: Partial<Record<string, unknown>> = {}) {
return {
token: TOKEN,
grantId: GRANT_ID,
peerId: PEER_ID,
expiresAt: new Date(Date.now() + 60_000), // 1 min from now
usedAt: null,
createdAt: new Date(),
...overrides,
};
}
function makeGrant(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: GRANT_ID,
peerId: PEER_ID,
subjectUserId: USER_ID,
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
status: 'pending',
expiresAt: null,
createdAt: new Date(),
revokedAt: null,
revokedReason: null,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Mock DB builder
// ---------------------------------------------------------------------------
function makeDb({
tokenRows = [makeTokenRow()],
// claimedRows is returned by the .returning() on the token-claim UPDATE.
// Empty array = concurrent request won the race (GoneException).
claimedRows = [{ token: TOKEN }],
}: {
tokenRows?: unknown[];
claimedRows?: unknown[];
} = {}) {
// insert().values() — for createToken (outer db, not tx)
const insertValues = vi.fn().mockResolvedValue(undefined);
const insertMock = vi.fn().mockReturnValue({ values: insertValues });
// select().from().where().limit() — for fetching the token row
const limitSelect = vi.fn().mockResolvedValue(tokenRows);
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
// update().set().where().returning() — for the atomic token claim (outer db)
const returningMock = vi.fn().mockResolvedValue(claimedRows);
const whereClaimUpdate = vi.fn().mockReturnValue({ returning: returningMock });
const setClaimMock = vi.fn().mockReturnValue({ where: whereClaimUpdate });
const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock });
// transaction(cb) — cb receives txMock; txMock has update + insert
const txInsertValues = vi.fn().mockResolvedValue(undefined);
const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues });
const txWhereUpdate = vi.fn().mockResolvedValue(undefined);
const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate });
const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock });
const txMock = { update: txUpdateMock, insert: txInsertMock };
const transactionMock = vi
.fn()
.mockImplementation(async (cb: (tx: typeof txMock) => Promise<void>) => cb(txMock));
return {
insert: insertMock,
select: selectMock,
update: claimUpdateMock,
transaction: transactionMock,
_mocks: {
insertValues,
insertMock,
limitSelect,
whereSelect,
fromSelect,
selectMock,
returningMock,
whereClaimUpdate,
setClaimMock,
claimUpdateMock,
txInsertValues,
txInsertMock,
txWhereUpdate,
txSetMock,
txUpdateMock,
txMock,
transactionMock,
},
};
}
// ---------------------------------------------------------------------------
// Mock CaService
// ---------------------------------------------------------------------------
function makeCaService() {
return {
issueCert: vi.fn().mockResolvedValue({
certPem: MOCK_CERT_PEM,
certChainPem: MOCK_CHAIN_PEM,
serialNumber: MOCK_SERIAL,
}),
};
}
// ---------------------------------------------------------------------------
// Mock GrantsService
// ---------------------------------------------------------------------------
function makeGrantsService(grantOverrides: Partial<Record<string, unknown>> = {}) {
return {
getGrant: vi.fn().mockResolvedValue(makeGrant(grantOverrides)),
activateGrant: vi.fn().mockResolvedValue(makeGrant({ status: 'active' })),
};
}
// ---------------------------------------------------------------------------
// Helper: build service under test
// ---------------------------------------------------------------------------
function buildService({
db = makeDb(),
caService = makeCaService(),
grantsService = makeGrantsService(),
}: {
db?: ReturnType<typeof makeDb>;
caService?: ReturnType<typeof makeCaService>;
grantsService?: ReturnType<typeof makeGrantsService>;
} = {}) {
return new EnrollmentService(db as unknown as Db, caService as never, grantsService as never);
}
// ---------------------------------------------------------------------------
// Tests: createToken
// ---------------------------------------------------------------------------
describe('EnrollmentService.createToken', () => {
it('inserts a token row and returns { token, expiresAt }', async () => {
const db = makeDb();
const service = buildService({ db });
const result = await service.createToken({
grantId: GRANT_ID,
peerId: PEER_ID,
ttlSeconds: 900,
});
expect(result.token).toHaveLength(64); // 32 bytes hex
expect(result.expiresAt).toBeDefined();
expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now());
expect(db._mocks.insertValues).toHaveBeenCalledWith(
expect.objectContaining({ grantId: GRANT_ID, peerId: PEER_ID }),
);
});
it('clamps ttlSeconds to 900', async () => {
const db = makeDb();
const service = buildService({ db });
const before = Date.now();
const result = await service.createToken({
grantId: GRANT_ID,
peerId: PEER_ID,
ttlSeconds: 9999,
});
const after = Date.now();
const expiresMs = new Date(result.expiresAt).getTime();
// Should be at most 900s from now
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
});
});
// ---------------------------------------------------------------------------
// Tests: redeem — error paths
// ---------------------------------------------------------------------------
describe('EnrollmentService.redeem — error paths', () => {
it('throws NotFoundException when token row not found', async () => {
const db = makeDb({ tokenRows: [] });
const service = buildService({ db });
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(NotFoundException);
});
it('throws GoneException when usedAt is set (already redeemed)', async () => {
const db = makeDb({ tokenRows: [makeTokenRow({ usedAt: new Date(Date.now() - 1000) })] });
const service = buildService({ db });
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
});
it('throws GoneException when token has expired', async () => {
const db = makeDb({ tokenRows: [makeTokenRow({ expiresAt: new Date(Date.now() - 1000) })] });
const service = buildService({ db });
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
});
it('throws GoneException when grant status is not pending', async () => {
const db = makeDb();
const grantsService = makeGrantsService({ status: 'active' });
const service = buildService({ db, grantsService });
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
});
it('throws GoneException when token claim UPDATE returns empty array (concurrent replay)', async () => {
const db = makeDb({ claimedRows: [] });
const caService = makeCaService();
const grantsService = makeGrantsService();
const service = buildService({ db, caService, grantsService });
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
});
it('does NOT call issueCert when token claim fails (no double minting)', async () => {
const db = makeDb({ claimedRows: [] });
const caService = makeCaService();
const service = buildService({ db, caService });
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
expect(caService.issueCert).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Tests: redeem — success path
// ---------------------------------------------------------------------------
describe('EnrollmentService.redeem — success path', () => {
let db: ReturnType<typeof makeDb>;
let caService: ReturnType<typeof makeCaService>;
let grantsService: ReturnType<typeof makeGrantsService>;
let service: EnrollmentService;
beforeEach(() => {
db = makeDb();
caService = makeCaService();
grantsService = makeGrantsService();
service = buildService({ db, caService, grantsService });
});
it('claims token BEFORE calling issueCert (prevents double minting)', async () => {
const callOrder: string[] = [];
db._mocks.returningMock.mockImplementation(async () => {
callOrder.push('claim');
return [{ token: TOKEN }];
});
caService.issueCert.mockImplementation(async () => {
callOrder.push('issueCert');
return { certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, serialNumber: MOCK_SERIAL };
});
await service.redeem(TOKEN, MOCK_CERT_PEM);
expect(callOrder).toEqual(['claim', 'issueCert']);
});
it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
expect(caService.issueCert).toHaveBeenCalledWith(
expect.objectContaining({
grantId: GRANT_ID,
subjectUserId: USER_ID,
csrPem: MOCK_CERT_PEM,
ttlSeconds: 300,
}),
);
});
it('runs activate grant + peer update + audit inside a transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
expect(db._mocks.transactionMock).toHaveBeenCalledOnce();
// tx.update called twice: activate grant + update peer
expect(db._mocks.txUpdateMock).toHaveBeenCalledTimes(2);
// tx.insert called once: audit log
expect(db._mocks.txInsertMock).toHaveBeenCalledOnce();
});
it('activates grant (sets status=active) inside the transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }));
});
it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
expect(db._mocks.txSetMock).toHaveBeenCalledWith(
expect.objectContaining({
certPem: MOCK_CERT_PEM,
certSerial: MOCK_SERIAL,
state: 'active',
}),
);
});
it('inserts an audit log row inside the transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM);
expect(db._mocks.txInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
peerId: PEER_ID,
grantId: GRANT_ID,
verb: 'enrollment',
}),
);
});
it('returns { certPem, certChainPem } from CaService', async () => {
const result = await service.redeem(TOKEN, MOCK_CERT_PEM);
expect(result).toEqual({
certPem: MOCK_CERT_PEM,
certChainPem: MOCK_CHAIN_PEM,
});
});
});

View File

@@ -0,0 +1,212 @@
/**
* Unit tests for FederationController (FED-M2-08).
*
* Coverage:
* - listGrants: delegates to GrantsService with query params
* - createGrant: delegates to GrantsService, validates body
* - generateToken: returns enrollmentUrl containing the token
* - listPeers: returns DB rows
*/
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NotFoundException } from '@nestjs/common';
import type { Db } from '@mosaicstack/db';
import { FederationController } from '../federation.controller.js';
import type { GrantsService } from '../grants.service.js';
import type { EnrollmentService } from '../enrollment.service.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
const MOCK_GRANT = {
id: GRANT_ID,
peerId: PEER_ID,
subjectUserId: USER_ID,
scope: { resources: ['tasks'], operations: ['list'] },
status: 'pending' as const,
expiresAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
revokedAt: null,
revokedReason: null,
};
const MOCK_PEER = {
id: PEER_ID,
commonName: 'test-peer',
displayName: 'Test Peer',
certPem: '',
certSerial: 'pending',
certNotAfter: new Date(0),
clientKeyPem: null,
state: 'pending' as const,
endpointUrl: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
// ---------------------------------------------------------------------------
// DB mock builder
// ---------------------------------------------------------------------------
function makeDbMock(rows: unknown[] = []) {
const orderBy = vi.fn().mockResolvedValue(rows);
const where = vi.fn().mockReturnValue({ orderBy });
const from = vi.fn().mockReturnValue({ where, orderBy });
const select = vi.fn().mockReturnValue({ from });
return {
select,
from,
where,
orderBy,
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as unknown as Db;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FederationController', () => {
let db: Db;
let grantsService: GrantsService;
let enrollmentService: EnrollmentService;
let controller: FederationController;
beforeEach(() => {
db = makeDbMock([MOCK_PEER]);
grantsService = {
createGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
getGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
listGrants: vi.fn().mockResolvedValue([MOCK_GRANT]),
revokeGrant: vi.fn().mockResolvedValue({ ...MOCK_GRANT, status: 'revoked' }),
activateGrant: vi.fn(),
expireGrant: vi.fn(),
} as unknown as GrantsService;
enrollmentService = {
createToken: vi.fn().mockResolvedValue({
token: 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12',
expiresAt: '2026-01-01T00:15:00.000Z',
}),
redeem: vi.fn(),
} as unknown as EnrollmentService;
controller = new FederationController(db, grantsService, enrollmentService);
});
// ─── Grant management ──────────────────────────────────────────────────
describe('listGrants', () => {
it('delegates to GrantsService with provided query params', async () => {
const query = { peerId: PEER_ID, status: 'pending' as const };
const result = await controller.listGrants(query);
expect(grantsService.listGrants).toHaveBeenCalledWith(query);
expect(result).toEqual([MOCK_GRANT]);
});
it('delegates to GrantsService with empty filters', async () => {
const result = await controller.listGrants({});
expect(grantsService.listGrants).toHaveBeenCalledWith({});
expect(result).toEqual([MOCK_GRANT]);
});
});
describe('createGrant', () => {
it('delegates to GrantsService and returns created grant', async () => {
const body = {
peerId: PEER_ID,
subjectUserId: USER_ID,
scope: { resources: ['tasks'], operations: ['list'] },
};
const result = await controller.createGrant(body);
expect(grantsService.createGrant).toHaveBeenCalledWith(body);
expect(result).toEqual(MOCK_GRANT);
});
});
describe('getGrant', () => {
it('delegates to GrantsService with provided ID', async () => {
const result = await controller.getGrant(GRANT_ID);
expect(grantsService.getGrant).toHaveBeenCalledWith(GRANT_ID);
expect(result).toEqual(MOCK_GRANT);
});
});
describe('revokeGrant', () => {
it('delegates to GrantsService with id and reason', async () => {
const result = await controller.revokeGrant(GRANT_ID, { reason: 'test reason' });
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, 'test reason');
expect(result).toMatchObject({ status: 'revoked' });
});
it('delegates without reason when omitted', async () => {
await controller.revokeGrant(GRANT_ID, {});
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, undefined);
});
});
describe('generateToken', () => {
it('returns enrollmentUrl containing the token', async () => {
const token = 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12';
vi.mocked(enrollmentService.createToken).mockResolvedValueOnce({
token,
expiresAt: '2026-01-01T00:15:00.000Z',
});
const result = await controller.generateToken(GRANT_ID, { ttlSeconds: 900 });
expect(result.token).toBe(token);
expect(result.enrollmentUrl).toContain(token);
expect(result.enrollmentUrl).toContain('/api/federation/enrollment/');
});
it('creates token via EnrollmentService with correct grantId and peerId', async () => {
await controller.generateToken(GRANT_ID, { ttlSeconds: 300 });
expect(enrollmentService.createToken).toHaveBeenCalledWith({
grantId: GRANT_ID,
peerId: PEER_ID,
ttlSeconds: 300,
});
});
it('throws NotFoundException when grant does not exist', async () => {
vi.mocked(grantsService.getGrant).mockRejectedValueOnce(
new NotFoundException(`Grant ${GRANT_ID} not found`),
);
await expect(controller.generateToken(GRANT_ID, { ttlSeconds: 900 })).rejects.toThrow(
NotFoundException,
);
});
});
// ─── Peer management ───────────────────────────────────────────────────
describe('listPeers', () => {
it('returns DB rows ordered by commonName', async () => {
const result = await controller.listPeers();
expect(db.select).toHaveBeenCalled();
// The DB mock resolves with [MOCK_PEER]
expect(result).toEqual([MOCK_PEER]);
});
});
});

View File

@@ -0,0 +1,351 @@
/**
* Unit tests for GrantsService — federation grants CRUD + status transitions (FED-M2-06).
*
* Coverage:
* - createGrant: validates scope via parseFederationScope
* - createGrant: inserts with status 'pending'
* - getGrant: returns grant when found
* - getGrant: throws NotFoundException when not found
* - listGrants: no filters returns all grants
* - listGrants: filters by peerId
* - listGrants: filters by subjectUserId
* - listGrants: filters by status
* - listGrants: multiple filters combined
* - activateGrant: pending → active works
* - activateGrant: non-pending throws ConflictException
* - revokeGrant: active → revoked works, sets revokedAt
* - revokeGrant: non-active throws ConflictException
* - expireGrant: active → expired works
* - expireGrant: non-active throws ConflictException
*/
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConflictException, NotFoundException } from '@nestjs/common';
import type { Db } from '@mosaicstack/db';
import { GrantsService } from '../grants.service.js';
import { FederationScopeError } from '../scope-schema.js';
// ---------------------------------------------------------------------------
// Minimal valid federation scope for testing
// ---------------------------------------------------------------------------
const VALID_SCOPE = {
resources: ['tasks'] as const,
excluded_resources: [],
max_rows_per_query: 100,
};
const PEER_ID = 'a1111111-1111-1111-1111-111111111111';
const USER_ID = 'u2222222-2222-2222-2222-222222222222';
const GRANT_ID = 'g3333333-3333-3333-3333-333333333333';
// ---------------------------------------------------------------------------
// Build a mock DB that mimics chained Drizzle query builder calls
// ---------------------------------------------------------------------------
function makeMockGrant(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: GRANT_ID,
peerId: PEER_ID,
subjectUserId: USER_ID,
scope: VALID_SCOPE,
status: 'pending',
expiresAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
revokedAt: null,
revokedReason: null,
...overrides,
};
}
function makeDb(
overrides: {
insertReturning?: unknown[];
selectRows?: unknown[];
updateReturning?: unknown[];
} = {},
) {
const insertReturning = overrides.insertReturning ?? [makeMockGrant()];
const selectRows = overrides.selectRows ?? [makeMockGrant()];
const updateReturning = overrides.updateReturning ?? [makeMockGrant({ status: 'active' })];
// Drizzle returns a chainable builder; we need to mock the full chain.
const returningInsert = vi.fn().mockResolvedValue(insertReturning);
const valuesInsert = vi.fn().mockReturnValue({ returning: returningInsert });
const insertMock = vi.fn().mockReturnValue({ values: valuesInsert });
// select().from().where().limit()
const limitSelect = vi.fn().mockResolvedValue(selectRows);
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
// from returns something that is both thenable (for full-table select) and has .where()
const fromSelect = vi.fn().mockReturnValue({
where: whereSelect,
limit: limitSelect,
// Make it thenable for listGrants with no filters (await db.select().from(federationGrants))
then: (resolve: (v: unknown) => unknown) => resolve(selectRows),
});
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
const returningUpdate = vi.fn().mockResolvedValue(updateReturning);
const whereUpdate = vi.fn().mockReturnValue({ returning: returningUpdate });
const setMock = vi.fn().mockReturnValue({ where: whereUpdate });
const updateMock = vi.fn().mockReturnValue({ set: setMock });
return {
insert: insertMock,
select: selectMock,
update: updateMock,
// Expose internals for assertions
_mocks: {
insertReturning,
valuesInsert,
insertMock,
limitSelect,
whereSelect,
fromSelect,
selectMock,
returningUpdate,
whereUpdate,
setMock,
updateMock,
},
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GrantsService', () => {
let db: ReturnType<typeof makeDb>;
let service: GrantsService;
beforeEach(() => {
db = makeDb();
service = new GrantsService(db as unknown as Db);
});
// ─── createGrant ──────────────────────────────────────────────────────────
describe('createGrant', () => {
it('calls parseFederationScope — rejects an invalid scope', async () => {
const invalidScope = { resources: [], max_rows_per_query: 0 };
await expect(
service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: invalidScope }),
).rejects.toBeInstanceOf(FederationScopeError);
});
it('inserts a grant with status pending and returns it', async () => {
const result = await service.createGrant({
peerId: PEER_ID,
subjectUserId: USER_ID,
scope: VALID_SCOPE,
});
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
expect.objectContaining({ status: 'pending', peerId: PEER_ID, subjectUserId: USER_ID }),
);
expect(result.status).toBe('pending');
});
it('passes expiresAt as a Date when provided', async () => {
await service.createGrant({
peerId: PEER_ID,
subjectUserId: USER_ID,
scope: VALID_SCOPE,
expiresAt: '2027-01-01T00:00:00Z',
});
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
expect.objectContaining({ expiresAt: expect.any(Date) }),
);
});
it('sets expiresAt to null when not provided', async () => {
await service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: VALID_SCOPE });
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
expect.objectContaining({ expiresAt: null }),
);
});
});
// ─── getGrant ─────────────────────────────────────────────────────────────
describe('getGrant', () => {
it('returns the grant when found', async () => {
const result = await service.getGrant(GRANT_ID);
expect(result.id).toBe(GRANT_ID);
});
it('throws NotFoundException when no rows returned', async () => {
db = makeDb({ selectRows: [] });
service = new GrantsService(db as unknown as Db);
await expect(service.getGrant(GRANT_ID)).rejects.toBeInstanceOf(NotFoundException);
});
});
// ─── listGrants ───────────────────────────────────────────────────────────
describe('listGrants', () => {
it('queries without where clause when no filters provided', async () => {
const result = await service.listGrants({});
expect(Array.isArray(result)).toBe(true);
});
it('applies peerId filter', async () => {
await service.listGrants({ peerId: PEER_ID });
expect(db._mocks.whereSelect).toHaveBeenCalled();
});
it('applies subjectUserId filter', async () => {
await service.listGrants({ subjectUserId: USER_ID });
expect(db._mocks.whereSelect).toHaveBeenCalled();
});
it('applies status filter', async () => {
await service.listGrants({ status: 'active' });
expect(db._mocks.whereSelect).toHaveBeenCalled();
});
it('applies multiple filters combined', async () => {
await service.listGrants({ peerId: PEER_ID, status: 'pending' });
expect(db._mocks.whereSelect).toHaveBeenCalled();
});
});
// ─── activateGrant ────────────────────────────────────────────────────────
describe('activateGrant', () => {
it('transitions pending → active and returns updated grant', async () => {
db = makeDb({
selectRows: [makeMockGrant({ status: 'pending' })],
updateReturning: [makeMockGrant({ status: 'active' })],
});
service = new GrantsService(db as unknown as Db);
const result = await service.activateGrant(GRANT_ID);
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'active' });
expect(result.status).toBe('active');
});
it('throws ConflictException when grant is already active', async () => {
db = makeDb({ selectRows: [makeMockGrant({ status: 'active' })] });
service = new GrantsService(db as unknown as Db);
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
});
it('throws ConflictException when grant is revoked', async () => {
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
service = new GrantsService(db as unknown as Db);
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
});
it('throws ConflictException when grant is expired', async () => {
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
service = new GrantsService(db as unknown as Db);
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
});
});
// ─── revokeGrant ──────────────────────────────────────────────────────────
describe('revokeGrant', () => {
it('transitions active → revoked and sets revokedAt', async () => {
const revokedAt = new Date();
db = makeDb({
selectRows: [makeMockGrant({ status: 'active' })],
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt })],
});
service = new GrantsService(db as unknown as Db);
const result = await service.revokeGrant(GRANT_ID, 'test reason');
expect(db._mocks.setMock).toHaveBeenCalledWith(
expect.objectContaining({
status: 'revoked',
revokedAt: expect.any(Date),
revokedReason: 'test reason',
}),
);
expect(result.status).toBe('revoked');
});
it('sets revokedReason to null when not provided', async () => {
db = makeDb({
selectRows: [makeMockGrant({ status: 'active' })],
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt: new Date() })],
});
service = new GrantsService(db as unknown as Db);
await service.revokeGrant(GRANT_ID);
expect(db._mocks.setMock).toHaveBeenCalledWith(
expect.objectContaining({ revokedReason: null }),
);
});
it('throws ConflictException when grant is pending', async () => {
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
service = new GrantsService(db as unknown as Db);
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
});
it('throws ConflictException when grant is already revoked', async () => {
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
service = new GrantsService(db as unknown as Db);
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
});
it('throws ConflictException when grant is expired', async () => {
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
service = new GrantsService(db as unknown as Db);
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
});
});
// ─── expireGrant ──────────────────────────────────────────────────────────
describe('expireGrant', () => {
it('transitions active → expired and returns updated grant', async () => {
db = makeDb({
selectRows: [makeMockGrant({ status: 'active' })],
updateReturning: [makeMockGrant({ status: 'expired' })],
});
service = new GrantsService(db as unknown as Db);
const result = await service.expireGrant(GRANT_ID);
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'expired' });
expect(result.status).toBe('expired');
});
it('throws ConflictException when grant is pending', async () => {
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
service = new GrantsService(db as unknown as Db);
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
});
it('throws ConflictException when grant is already expired', async () => {
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
service = new GrantsService(db as unknown as Db);
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
});
it('throws ConflictException when grant is revoked', async () => {
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
service = new GrantsService(db as unknown as Db);
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
});
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { sealClientKey, unsealClientKey } from '../peer-key.util.js';
const TEST_SECRET = 'test-secret-for-peer-key-unit-tests-only';
const TEST_PEM = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo
pCOW8QqstpxEBpnFo37JxLYEJbpE3gUlJajsHv9UWRQ7m5B7n+MBXwTCQqMEY8Wl
kHv9tGgz1YGwzBjNKxPJXE6pPTXQ1Oa0VB9l3qHdqF5HtZoJzE0c6dO8HJ5YUVL
-----END PRIVATE KEY-----`;
let savedSecret: string | undefined;
beforeEach(() => {
savedSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
});
afterEach(() => {
if (savedSecret === undefined) {
delete process.env['BETTER_AUTH_SECRET'];
} else {
process.env['BETTER_AUTH_SECRET'] = savedSecret;
}
});
describe('peer-key seal/unseal', () => {
it('round-trip: unsealClientKey(sealClientKey(pem)) returns original pem', () => {
const sealed = sealClientKey(TEST_PEM);
const roundTripped = unsealClientKey(sealed);
expect(roundTripped).toBe(TEST_PEM);
});
it('non-determinism: sealClientKey produces different ciphertext each call', () => {
const sealed1 = sealClientKey(TEST_PEM);
const sealed2 = sealClientKey(TEST_PEM);
expect(sealed1).not.toBe(sealed2);
});
it('at-rest: sealed output does not contain plaintext PEM content', () => {
const sealed = sealClientKey(TEST_PEM);
expect(sealed).not.toContain('PRIVATE KEY');
expect(sealed).not.toContain(
'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TB3wo',
);
});
it('tamper: flipping a byte in the sealed payload causes unseal to throw', () => {
const sealed = sealClientKey(TEST_PEM);
const buf = Buffer.from(sealed, 'base64');
// Flip a byte in the middle of the buffer (past IV and authTag)
const midpoint = Math.floor(buf.length / 2);
buf[midpoint] = buf[midpoint]! ^ 0xff;
const tampered = buf.toString('base64');
expect(() => unsealClientKey(tampered)).toThrow();
});
it('missing secret: unsealClientKey throws when BETTER_AUTH_SECRET is unset', () => {
const sealed = sealClientKey(TEST_PEM);
delete process.env['BETTER_AUTH_SECRET'];
expect(() => unsealClientKey(sealed)).toThrow('BETTER_AUTH_SECRET is not set');
});
});

View File

@@ -0,0 +1,57 @@
/**
* DTOs for the Step-CA client service (FED-M2-04).
*
* IssueCertRequestDto — input to CaService.issueCert()
* IssuedCertDto — output from CaService.issueCert()
*/
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class IssueCertRequestDto {
/**
* PEM-encoded PKCS#10 Certificate Signing Request.
* The CSR must already include the desired SANs.
*/
@IsString()
@IsNotEmpty()
csrPem!: string;
/**
* UUID of the federation_grants row this certificate is being issued for.
* Embedded as the `mosaic_grant_id` custom OID extension.
*/
@IsUUID()
grantId!: string;
/**
* UUID of the local user on whose behalf the cert is being issued.
* Embedded as the `mosaic_subject_user_id` custom OID extension.
*/
@IsUUID()
subjectUserId!: string;
/**
* Requested certificate validity in seconds.
* Hard cap: 900 s (15 minutes). Default: 300 s (5 minutes).
* The service will always clamp to 900 s regardless of this value.
*/
@IsOptional()
@IsInt()
@Min(60)
@Max(15 * 60)
ttlSeconds: number = 300;
}
export class IssuedCertDto {
/** PEM-encoded leaf certificate returned by step-ca. */
certPem!: string;
/**
* PEM-encoded full certificate chain (leaf + intermediates + root).
* Falls back to `certPem` when step-ca returns no `certChain` field.
*/
certChainPem!: string;
/** Decimal serial number string of the issued certificate. */
serialNumber!: string;
}

View File

@@ -0,0 +1,577 @@
/**
* Unit tests for CaService — Step-CA client (FED-M2-04).
*
* Coverage:
* - Happy path: returns IssuedCertDto with certPem, certChainPem, serialNumber
* - certChainPem fallback: falls back to certPem when certChain absent
* - certChainPem from ca field: uses crt+ca when certChain absent but ca present
* - HTTP 401: throws CaServiceError with cause + remediation
* - HTTP non-401 error: throws CaServiceError
* - Malformed CSR: throws before HTTP call (INVALID_CSR)
* - Non-JSON response: throws CaServiceError
* - HTTPS connection error: throws CaServiceError
* - JWT custom claims: mosaic_grant_id and mosaic_subject_user_id present in OTT payload
* verified with jose.jwtVerify (real signature check)
* - CaServiceError: has cause + remediation properties
* - Missing crt in response: throws CaServiceError
* - Real CSR validation: valid P-256 CSR passes; malformed CSR fails with INVALID_CSR
* - provisionerPassword never appears in CaServiceError messages
* - HTTPS-only enforcement: http:// URL throws in constructor
*/
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { jwtVerify, exportJWK, generateKeyPair } from 'jose';
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
// ---------------------------------------------------------------------------
// Mock node:https BEFORE importing CaService so the mock is in place when
// the module is loaded. Vitest/ESM require vi.mock at the top level.
// ---------------------------------------------------------------------------
vi.mock('node:https', () => {
const mockRequest = vi.fn();
const mockAgent = vi.fn().mockImplementation(() => ({}));
return {
default: { request: mockRequest, Agent: mockAgent },
request: mockRequest,
Agent: mockAgent,
};
});
vi.mock('node:fs', () => {
const mockReadFileSync = vi
.fn()
.mockReturnValue('-----BEGIN CERTIFICATE-----\nFAKEROOT\n-----END CERTIFICATE-----\n');
return {
default: { readFileSync: mockReadFileSync },
readFileSync: mockReadFileSync,
};
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Real self-signed EC P-256 certificate generated with openssl for testing.
// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout /dev/null \
// -out /dev/stdout -subj "/CN=test" -days 1
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIBdDCCARmgAwIBAgIUM+iUJSayN+PwXkyVN6qwSY7sr6gwCgYIKoZIzj0EAwIw
DzENMAsGA1UEAwwEdGVzdDAeFw0yNjA0MjIwMzE5MTlaFw0yNjA0MjMwMzE5MTla
MA8xDTALBgNVBAMMBHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR21kHL
n1GmFQ4TEBw3EA53pD+2McIBf5WcoHE+x0eMz5DpRKJe0ksHwOVN5Yev5d57kb+4
MvG1LhbHCB/uQo8So1MwUTAdBgNVHQ4EFgQUPq0pdIGiQ7pLBRXICS8GTliCrLsw
HwYDVR0jBBgwFoAUPq0pdIGiQ7pLBRXICS8GTliCrLswDwYDVR0TAQH/BAUwAwEB
/zAKBggqhkjOPQQDAgNJADBGAiEAypJqyC6S77aQ3eEXokM6sgAsD7Oa3tJbCbVm
zG3uJb0CIQC1w+GE+Ad0OTR5Quja46R1RjOo8ydpzZ7Fh4rouAiwEw==
-----END CERTIFICATE-----
`;
// Use a second copy of the same cert for the CA field in tests.
const FAKE_CA_PEM = FAKE_CERT_PEM;
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22';
// ---------------------------------------------------------------------------
// Generate a real EC P-256 key pair and CSR for integration-style tests
// ---------------------------------------------------------------------------
// We generate this once at module level so it's available to all tests.
// The key pair and CSR PEM are populated asynchronously in the test that needs them.
let realCsrPem: string;
async function generateRealCsr(): Promise<string> {
const { privateKey, publicKey } = await generateKeyPair('ES256');
// Export public key JWK for potential verification (not used here but confirms key is exportable)
await exportJWK(publicKey);
// Use @peculiar/x509 to build a proper CSR
const csr = await Pkcs10CertificateRequestGenerator.create({
name: 'CN=test.federation.local',
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
keys: { privateKey, publicKey },
});
return csr.toString('pem');
}
// ---------------------------------------------------------------------------
// Setup env before importing service
// We use an EC P-256 key pair here so the JWK-based signing works.
// The key pair is generated once and stored in module-level vars.
// ---------------------------------------------------------------------------
// Real EC P-256 test JWK (test-only, never used in production).
// Generated with node webcrypto for use in unit tests.
const TEST_EC_PRIVATE_JWK = {
key_ops: ['sign'],
ext: true,
kty: 'EC',
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
crv: 'P-256',
d: 'TM6N79w1HE-PiML5Td4mbXfJaLHEaZrVyVrrwlJv7q8',
kid: 'test-ec-kid',
};
const TEST_EC_PUBLIC_JWK = {
key_ops: ['verify'],
ext: true,
kty: 'EC',
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
crv: 'P-256',
kid: 'test-ec-kid',
};
process.env['STEP_CA_URL'] = 'https://step-ca:9000';
process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JSON.stringify(TEST_EC_PRIVATE_JWK);
process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem';
// Import AFTER env is set and mocks are registered
import * as httpsModule from 'node:https';
import { CaService, CaServiceError } from './ca.service.js';
import type { IssueCertRequestDto } from './ca.dto.js';
// ---------------------------------------------------------------------------
// Helper to build a mock https.request that simulates step-ca
// ---------------------------------------------------------------------------
function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): void {
const mockReq = {
write: vi.fn(),
end: vi.fn(),
on: vi.fn(),
setTimeout: vi.fn(),
};
(httpsModule.request as unknown as Mock).mockImplementation(
(
_options: unknown,
callback: (res: {
statusCode: number;
on: (event: string, cb: (chunk?: Buffer) => void) => void;
}) => void,
) => {
const mockRes = {
statusCode,
on: (event: string, cb: (chunk?: Buffer) => void) => {
if (event === 'data') {
if (body !== undefined) {
cb(Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)));
}
}
if (event === 'end') {
cb();
}
},
};
if (errorMsg) {
// Simulate a connection error via the req.on('error') handler
mockReq.on.mockImplementation((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
setImmediate(() => cb(new Error(errorMsg)));
}
});
} else {
// Normal flow: call the response callback
setImmediate(() => callback(mockRes));
}
return mockReq;
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('CaService', () => {
let service: CaService;
beforeEach(() => {
vi.clearAllMocks();
service = new CaService();
});
function makeReq(overrides: Partial<IssueCertRequestDto> = {}): IssueCertRequestDto {
// Use a real CSR if available; fall back to a minimal placeholder
const defaultCsr = realCsrPem ?? makeFakeCsr();
return {
csrPem: defaultCsr,
grantId: GRANT_ID,
subjectUserId: SUBJECT_USER_ID,
ttlSeconds: 300,
...overrides,
};
}
function makeFakeCsr(): string {
// A structurally valid-looking CSR header/footer (body will fail crypto verify)
return `-----BEGIN CERTIFICATE REQUEST-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA\n-----END CERTIFICATE REQUEST-----\n`;
}
// -------------------------------------------------------------------------
// Real CSR generation — runs once and populates realCsrPem
// -------------------------------------------------------------------------
it('generates a real P-256 CSR that passes validateCsr', async () => {
realCsrPem = await generateRealCsr();
expect(realCsrPem).toMatch(/BEGIN CERTIFICATE REQUEST/);
// Now test that the service's validateCsr accepts it.
// We call it indirectly via issueCert with a successful mock.
makeHttpsMock(200, { crt: FAKE_CERT_PEM, certChain: [FAKE_CERT_PEM, FAKE_CA_PEM] });
const result = await service.issueCert(makeReq({ csrPem: realCsrPem }));
expect(result.certPem).toBe(FAKE_CERT_PEM);
});
it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => {
const malformedCsr =
'-----BEGIN CERTIFICATE REQUEST-----\nTm90QVJlYWxDU1I=\n-----END CERTIFICATE REQUEST-----\n';
await expect(service.issueCert(makeReq({ csrPem: malformedCsr }))).rejects.toSatisfy(
(err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.code).toBe('INVALID_CSR');
return true;
},
);
});
// -------------------------------------------------------------------------
// Happy path
// -------------------------------------------------------------------------
it('returns IssuedCertDto on success (certChain present)', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, {
crt: FAKE_CERT_PEM,
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM],
});
const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CA_PEM);
expect(typeof result.serialNumber).toBe('string');
});
// -------------------------------------------------------------------------
// certChainPem fallback — certChain absent, ca field present
// -------------------------------------------------------------------------
it('builds certChainPem from crt+ca when certChain is absent', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, {
crt: FAKE_CERT_PEM,
ca: FAKE_CA_PEM,
});
const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CA_PEM);
});
// -------------------------------------------------------------------------
// certChainPem fallback — no certChain, no ca field
// -------------------------------------------------------------------------
it('falls back to certPem alone when certChain and ca are absent', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, { crt: FAKE_CERT_PEM });
const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certChainPem).toBe(FAKE_CERT_PEM);
});
// -------------------------------------------------------------------------
// HTTP 401
// -------------------------------------------------------------------------
it('throws CaServiceError on HTTP 401', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(401, { message: 'Unauthorized' });
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.message).toMatch(/401/);
expect(err.remediation).toBeTruthy();
return true;
});
});
// -------------------------------------------------------------------------
// HTTP non-401 error (e.g. 422)
// -------------------------------------------------------------------------
it('throws CaServiceError on HTTP 422', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(422, { message: 'Unprocessable Entity' });
await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError);
});
// -------------------------------------------------------------------------
// Malformed CSR — throws before HTTP call
// -------------------------------------------------------------------------
it('throws CaServiceError for malformed CSR without making HTTP call', async () => {
const requestSpy = vi.spyOn(httpsModule, 'request');
await expect(service.issueCert(makeReq({ csrPem: 'not-a-valid-csr' }))).rejects.toBeInstanceOf(
CaServiceError,
);
expect(requestSpy).not.toHaveBeenCalled();
});
// -------------------------------------------------------------------------
// Non-JSON response
// -------------------------------------------------------------------------
it('throws CaServiceError when step-ca returns non-JSON', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, 'this is not json');
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.message).toMatch(/non-JSON/);
return true;
});
});
// -------------------------------------------------------------------------
// HTTPS connection error
// -------------------------------------------------------------------------
it('throws CaServiceError on HTTPS connection error', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000');
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.message).toMatch(/HTTPS connection/);
expect(err.cause).toBeInstanceOf(Error);
return true;
});
});
// -------------------------------------------------------------------------
// JWT custom claims: mosaic_grant_id and mosaic_subject_user_id
// Verified with jose.jwtVerify for real signature verification (M6)
// -------------------------------------------------------------------------
it('OTT contains mosaic_grant_id, mosaic_subject_user_id, and jti; signature verifies with jose', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
let capturedBody: Record<string, unknown> | undefined;
const mockReq = {
write: vi.fn((data: string) => {
capturedBody = JSON.parse(data) as Record<string, unknown>;
}),
end: vi.fn(),
on: vi.fn(),
setTimeout: vi.fn(),
};
(httpsModule.request as unknown as Mock).mockImplementation(
(
_options: unknown,
callback: (res: {
statusCode: number;
on: (event: string, cb: (chunk?: Buffer) => void) => void;
}) => void,
) => {
const mockRes = {
statusCode: 200,
on: (event: string, cb: (chunk?: Buffer) => void) => {
if (event === 'data') {
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
}
if (event === 'end') {
cb();
}
},
};
setImmediate(() => callback(mockRes));
return mockReq;
},
);
await service.issueCert(makeReq({ csrPem: realCsrPem }));
expect(capturedBody).toBeDefined();
const ott = capturedBody!['ott'] as string;
expect(typeof ott).toBe('string');
// Verify JWT structure
const parts = ott.split('.');
expect(parts).toHaveLength(3);
// Decode payload without signature check first
const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8');
const payload = JSON.parse(payloadJson) as Record<string, unknown>;
expect(payload['mosaic_grant_id']).toBe(GRANT_ID);
expect(payload['mosaic_subject_user_id']).toBe(SUBJECT_USER_ID);
expect(typeof payload['jti']).toBe('string'); // M2: jti present
expect(payload['jti']).toMatch(/^[0-9a-f-]{36}$/); // UUID format
// M3: top-level sha should NOT be present; step.sha should be present
expect(payload['sha']).toBeUndefined();
const step = payload['step'] as Record<string, unknown> | undefined;
expect(step?.['sha']).toBeDefined();
// M6: Verify signature with jose.jwtVerify using the public key
const { importJWK: importJose } = await import('jose');
const publicKey = await importJose(TEST_EC_PUBLIC_JWK, 'ES256');
const verified = await jwtVerify(ott, publicKey);
expect(verified.payload['mosaic_grant_id']).toBe(GRANT_ID);
});
// -------------------------------------------------------------------------
// CaServiceError has cause + remediation
// -------------------------------------------------------------------------
it('CaServiceError carries cause and remediation', () => {
const cause = new Error('original error');
const err = new CaServiceError('something went wrong', 'fix it like this', cause);
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(CaServiceError);
expect(err.message).toBe('something went wrong');
expect(err.remediation).toBe('fix it like this');
expect(err.cause).toBe(cause);
expect(err.name).toBe('CaServiceError');
});
// -------------------------------------------------------------------------
// Missing crt in response
// -------------------------------------------------------------------------
it('throws CaServiceError when response is missing the crt field', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, { ca: FAKE_CA_PEM });
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.message).toMatch(/missing the "crt" field/);
return true;
});
});
// -------------------------------------------------------------------------
// M6: provisionerPassword must never appear in CaServiceError messages
// -------------------------------------------------------------------------
it('provisionerPassword does not appear in any CaServiceError message', async () => {
// Temporarily set a recognizable password to test against
const originalPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'super-secret-password-12345';
// Generate a bad CSR to trigger an error path
const caughtErrors: CaServiceError[] = [];
try {
await service.issueCert(makeReq({ csrPem: 'not-a-csr' }));
} catch (err) {
if (err instanceof CaServiceError) {
caughtErrors.push(err);
}
}
// Also try HTTP 401 path
if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(401, { message: 'Unauthorized' });
try {
await service.issueCert(makeReq({ csrPem: realCsrPem }));
} catch (err) {
if (err instanceof CaServiceError) {
caughtErrors.push(err);
}
}
for (const err of caughtErrors) {
expect(err.message).not.toContain('super-secret-password-12345');
if (err.remediation) {
expect(err.remediation).not.toContain('super-secret-password-12345');
}
}
process.env['STEP_CA_PROVISIONER_PASSWORD'] = originalPassword;
});
// -------------------------------------------------------------------------
// M7: HTTPS-only enforcement in constructor
// -------------------------------------------------------------------------
it('throws in constructor if STEP_CA_URL uses http://', () => {
const originalUrl = process.env['STEP_CA_URL'];
process.env['STEP_CA_URL'] = 'http://step-ca:9000';
expect(() => new CaService()).toThrow(CaServiceError);
process.env['STEP_CA_URL'] = originalUrl;
});
// -------------------------------------------------------------------------
// TTL clamp: ttlSeconds is clamped to 900 s (15 min) maximum
// -------------------------------------------------------------------------
it('clamps ttlSeconds to 900 s regardless of input', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr();
let capturedBody: Record<string, unknown> | undefined;
const mockReq = {
write: vi.fn((data: string) => {
capturedBody = JSON.parse(data) as Record<string, unknown>;
}),
end: vi.fn(),
on: vi.fn(),
setTimeout: vi.fn(),
};
(httpsModule.request as unknown as Mock).mockImplementation(
(
_options: unknown,
callback: (res: {
statusCode: number;
on: (event: string, cb: (chunk?: Buffer) => void) => void;
}) => void,
) => {
const mockRes = {
statusCode: 200,
on: (event: string, cb: (chunk?: Buffer) => void) => {
if (event === 'data') {
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
}
if (event === 'end') {
cb();
}
},
};
setImmediate(() => callback(mockRes));
return mockReq;
},
);
// Request 86400 s — should be clamped to 900
await service.issueCert(makeReq({ ttlSeconds: 86400 }));
expect(capturedBody).toBeDefined();
const validity = capturedBody!['validity'] as Record<string, unknown>;
expect(validity['duration']).toBe('900s');
});
});

View File

@@ -0,0 +1,680 @@
/**
* CaService — Step-CA client for federation grant certificate issuance.
*
* Responsibilities:
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with the provisioner
* private key (ES256/ES384/RS256 per JWK kty/crv) carrying Mosaic-specific
* claims (`mosaic_grant_id`, `mosaic_subject_user_id`, `step.sha`) per the
* step-ca JWK provisioner protocol.
* 2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS,
* pinning the trust to the CA root cert supplied via env.
* 3. Return an IssuedCertDto containing the leaf cert, full chain, and
* serial number.
*
* Environment variables (all required at runtime — validated in constructor):
* STEP_CA_URL https://step-ca:9000
* STEP_CA_PROVISIONER_KEY_JSON JWK provisioner private key (JSON)
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
*
* Optional (only used for JWK PBES2 decrypt at startup if key is encrypted):
* STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string)
*
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
* 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
*
* Fail-loud contract:
* Every error path throws CaServiceError with a human-readable `remediation`
* field. Silent OID-stripping is NEVER allowed — if the sign response does
* not include the cert, we throw rather than return a cert that may be
* missing the custom extensions.
*/
import { Injectable, Logger } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as https from 'node:https';
import { SignJWT, importJWK } from 'jose';
import { Pkcs10CertificateRequest, X509Certificate } from '@peculiar/x509';
import type { IssueCertRequestDto } from './ca.dto.js';
import { IssuedCertDto } from './ca.dto.js';
// ---------------------------------------------------------------------------
// Custom error class
// ---------------------------------------------------------------------------
export class CaServiceError extends Error {
readonly cause: unknown;
readonly remediation: string;
readonly code?: string;
constructor(message: string, remediation: string, cause?: unknown, code?: string) {
super(message);
this.name = 'CaServiceError';
this.cause = cause;
this.remediation = remediation;
this.code = code;
}
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
interface StepSignResponse {
crt: string;
ca?: string;
certChain?: string[];
}
interface JwkKey {
kty: string;
kid?: string;
use?: string;
alg?: string;
k?: string; // symmetric
n?: string; // RSA
e?: string;
d?: string;
x?: string; // EC
y?: string;
crv?: string;
[key: string]: unknown;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** UUID regex for validation */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Derive the JWT algorithm string from a JWK's kty/crv fields.
* EC P-256 → ES256, EC P-384 → ES384, RSA → RS256.
*/
function algFromJwk(jwk: JwkKey): string {
if (jwk.alg) return jwk.alg;
if (jwk.kty === 'EC') {
if (jwk.crv === 'P-384') return 'ES384';
return 'ES256'; // default for P-256 and Ed25519-style EC keys
}
if (jwk.kty === 'RSA') return 'RS256';
throw new CaServiceError(
`Unsupported JWK kty: ${jwk.kty}`,
'STEP_CA_PROVISIONER_KEY_JSON must be an EC (P-256/P-384) or RSA JWK private key.',
);
}
/**
* Compute SHA-256 fingerprint of the DER-encoded CSR body.
* step-ca uses this as the `step.sha` claim to bind the OTT to a specific CSR.
*/
function csrFingerprint(csrPem: string): string {
// Strip PEM headers and decode base64 body
const b64 = csrPem
.replace(/-----BEGIN CERTIFICATE REQUEST-----/, '')
.replace(/-----END CERTIFICATE REQUEST-----/, '')
.replace(/\s+/g, '');
let derBuf: Buffer;
try {
derBuf = Buffer.from(b64, 'base64');
} catch (err) {
throw new CaServiceError(
'Failed to base64-decode the CSR PEM body',
'Verify that csrPem is a valid PKCS#10 PEM-encoded certificate request.',
err,
);
}
if (derBuf.length === 0) {
throw new CaServiceError(
'CSR PEM decoded to empty buffer — malformed input',
'Provide a valid non-empty PKCS#10 PEM-encoded certificate request.',
);
}
return crypto.createHash('sha256').update(derBuf).digest('hex');
}
/**
* Send a JSON POST to the step-ca sign endpoint.
* Returns the parsed response body or throws CaServiceError.
*/
function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<StepSignResponse> {
return new Promise((resolve, reject) => {
const bodyStr = JSON.stringify(body);
const parsed = new URL(url);
const options: https.RequestOptions = {
hostname: parsed.hostname,
port: parsed.port ? parseInt(parsed.port, 10) : 443,
path: parsed.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
},
agent,
timeout: 5000,
};
const req = https.request(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (res.statusCode === 401) {
reject(
new CaServiceError(
`step-ca returned HTTP 401 — invalid or expired OTT`,
'Check STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
),
);
return;
}
if (res.statusCode && res.statusCode >= 400) {
reject(
new CaServiceError(
`step-ca returned HTTP ${res.statusCode}: ${raw.slice(0, 256)}`,
`Review the step-ca logs. Status ${res.statusCode} may indicate a CSR policy violation or misconfigured provisioner.`,
),
);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch (err) {
reject(
new CaServiceError(
'step-ca returned a non-JSON response',
'Verify STEP_CA_URL points to a running step-ca instance and that TLS is properly configured.',
err,
),
);
return;
}
resolve(parsed as StepSignResponse);
});
});
req.setTimeout(5000, () => {
req.destroy(new Error('Request timed out after 5000ms'));
});
req.on('error', (err: Error) => {
reject(
new CaServiceError(
`HTTPS connection to step-ca failed: ${err.message}`,
'Ensure STEP_CA_URL is reachable and STEP_CA_ROOT_CERT_PATH points to the correct CA root certificate.',
err,
),
);
});
req.write(bodyStr);
req.end();
});
}
/**
* Extract a decimal serial number from a PEM certificate.
* Throws CaServiceError on failure — never silently returns 'unknown'.
*/
function extractSerial(certPem: string): string {
let cert: crypto.X509Certificate;
try {
cert = new crypto.X509Certificate(certPem);
} catch (err) {
throw new CaServiceError(
'Failed to parse the issued certificate PEM',
'The certificate returned by step-ca could not be parsed. Check that step-ca is returning a valid PEM certificate.',
err,
'CERT_PARSE',
);
}
return cert.serialNumber;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@Injectable()
export class CaService {
private readonly logger = new Logger(CaService.name);
private readonly caUrl: string;
private readonly rootCertPath: string;
private readonly httpsAgent: https.Agent;
private readonly jwk: JwkKey;
private cachedPrivateKey: crypto.KeyObject | null = null;
private readonly jwtAlg: string;
private readonly kid: string;
constructor() {
const caUrl = process.env['STEP_CA_URL'];
const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON'];
const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH'];
if (!caUrl) {
throw new CaServiceError(
'STEP_CA_URL is not set',
'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
);
}
// Enforce HTTPS-only URL
let parsedUrl: URL;
try {
parsedUrl = new URL(caUrl);
} catch (err) {
throw new CaServiceError(
`STEP_CA_URL is not a valid URL: ${caUrl}`,
'Set STEP_CA_URL to a valid HTTPS URL, e.g. https://step-ca:9000',
err,
);
}
if (parsedUrl.protocol !== 'https:') {
throw new CaServiceError(
`STEP_CA_URL must use HTTPS — got: ${parsedUrl.protocol}`,
'Set STEP_CA_URL to an https:// URL. Unencrypted connections to the CA are not permitted.',
);
}
if (!provisionerKeyJson) {
throw new CaServiceError(
'STEP_CA_PROVISIONER_KEY_JSON is not set',
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-encoded JWK for the mosaic-fed provisioner.',
);
}
if (!rootCertPath) {
throw new CaServiceError(
'STEP_CA_ROOT_CERT_PATH is not set',
'Set STEP_CA_ROOT_CERT_PATH to the absolute path of the step-ca root CA certificate PEM file.',
);
}
// Parse JWK once — do NOT store the raw JSON string as a class field
let jwk: JwkKey;
try {
jwk = JSON.parse(provisionerKeyJson) as JwkKey;
} catch (err) {
throw new CaServiceError(
'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON',
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.',
err,
);
}
// Derive algorithm from JWK metadata
const jwtAlg = algFromJwk(jwk);
const kid = jwk.kid ?? 'mosaic-fed';
// Import the JWK into a native KeyObject — fail loudly if it cannot be loaded.
// We do this synchronously here by calling the async importJWK via a blocking workaround.
// Actually importJWK is async, so we store it for use during token building.
// We keep the raw jwk object for later async import inside buildOtt.
// NOTE: We do NOT store provisionerKeyJson string as a class field.
this.jwk = jwk;
this.jwtAlg = jwtAlg;
this.kid = kid;
this.caUrl = caUrl;
this.rootCertPath = rootCertPath;
// Read the root cert and pin it for all HTTPS connections.
let rootCert: string;
try {
rootCert = fs.readFileSync(this.rootCertPath, 'utf8');
} catch (err) {
throw new CaServiceError(
`Cannot read STEP_CA_ROOT_CERT_PATH: ${rootCertPath}`,
'Ensure the file exists and is readable by the gateway process.',
err,
);
}
this.httpsAgent = new https.Agent({
ca: rootCert,
rejectUnauthorized: true,
});
this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`);
}
/**
* Lazily import the private key from JWK on first use.
* The key is cached in cachedPrivateKey after first import.
*/
private async getPrivateKey(): Promise<crypto.KeyObject> {
if (this.cachedPrivateKey !== null) return this.cachedPrivateKey;
try {
const key = await importJWK(this.jwk, this.jwtAlg);
// importJWK returns KeyLike (crypto.KeyObject | Uint8Array) — in Node.js it's KeyObject
this.cachedPrivateKey = key as unknown as crypto.KeyObject;
return this.cachedPrivateKey;
} catch (err) {
throw new CaServiceError(
'Failed to import STEP_CA_PROVISIONER_KEY_JSON as a cryptographic key',
'Ensure STEP_CA_PROVISIONER_KEY_JSON contains a valid JWK private key (EC P-256/P-384 or RSA).',
err,
);
}
}
/**
* Build the JWK-provisioner OTT signed with the provisioner private key.
* Algorithm is derived from the JWK kty/crv fields.
*/
private async buildOtt(params: {
csrPem: string;
grantId: string;
subjectUserId: string;
ttlSeconds: number;
csrCn: string;
}): Promise<string> {
const { csrPem, grantId, subjectUserId, ttlSeconds, csrCn } = params;
// Validate UUID shape for grant id and subject user id
if (!UUID_RE.test(grantId)) {
throw new CaServiceError(
`grantId is not a valid UUID: ${grantId}`,
'Provide a valid UUID (RFC 4122) for grantId.',
undefined,
'INVALID_GRANT_ID',
);
}
if (!UUID_RE.test(subjectUserId)) {
throw new CaServiceError(
`subjectUserId is not a valid UUID: ${subjectUserId}`,
'Provide a valid UUID (RFC 4122) for subjectUserId.',
undefined,
'INVALID_GRANT_ID',
);
}
const sha = csrFingerprint(csrPem);
const now = Math.floor(Date.now() / 1000);
const privateKey = await this.getPrivateKey();
const ott = await new SignJWT({
iss: this.kid,
sub: csrCn, // M1: set sub to identity from CSR CN
aud: [`${this.caUrl}/1.0/sign`],
iat: now,
nbf: now - 30, // 30 s clock-skew tolerance
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
jti: crypto.randomUUID(), // M2: unique token ID
// step.sha is the canonical field name used in the template — M3: keep only step.sha
step: { sha },
// Mosaic custom claims consumed by federation.tpl
mosaic_grant_id: grantId,
mosaic_subject_user_id: subjectUserId,
})
.setProtectedHeader({ alg: this.jwtAlg, typ: 'JWT', kid: this.kid })
.sign(privateKey);
return ott;
}
/**
* Validate a PEM-encoded CSR using @peculiar/x509.
* Verifies the self-signature, key type/size, and signature algorithm.
* Optionally verifies that the CSR's SANs match the expected set.
*
* Throws CaServiceError with code 'INVALID_CSR' on failure.
*/
private async validateCsr(pem: string, expectedSans?: string[]): Promise<string> {
let csr: Pkcs10CertificateRequest;
try {
csr = new Pkcs10CertificateRequest(pem);
} catch (err) {
throw new CaServiceError(
'Failed to parse CSR PEM as a valid PKCS#10 certificate request',
'Provide a valid PEM-encoded PKCS#10 CSR.',
err,
'INVALID_CSR',
);
}
// Verify self-signature
let valid: boolean;
try {
valid = await csr.verify();
} catch (err) {
throw new CaServiceError(
'CSR signature verification threw an error',
'The CSR self-signature could not be verified. Ensure the CSR is properly formed.',
err,
'INVALID_CSR',
);
}
if (!valid) {
throw new CaServiceError(
'CSR self-signature is invalid',
'The CSR must be self-signed with the corresponding private key.',
undefined,
'INVALID_CSR',
);
}
// Validate signature algorithm — reject MD5 and SHA-1
// signatureAlgorithm is HashedAlgorithm which extends Algorithm.
// Cast through unknown to access .name and .hash.name without DOM lib globals.
const sigAlgAny = csr.signatureAlgorithm as unknown as {
name?: string;
hash?: { name?: string };
};
const sigAlgName = (sigAlgAny.name ?? '').toLowerCase();
const hashName = (sigAlgAny.hash?.name ?? '').toLowerCase();
if (
sigAlgName.includes('md5') ||
sigAlgName.includes('sha1') ||
hashName === 'sha-1' ||
hashName === 'sha1'
) {
throw new CaServiceError(
`CSR uses a forbidden signature algorithm: ${sigAlgAny.name ?? 'unknown'}`,
'Use SHA-256 or stronger. MD5 and SHA-1 are not permitted.',
undefined,
'INVALID_CSR',
);
}
// Validate public key algorithm and strength via the algorithm descriptor on the key.
// csr.publicKey.algorithm is type Algorithm (WebCrypto) — use name-based checks.
// We cast to an extended interface to access curve/modulus info without DOM globals.
const pubKeyAlgo = csr.publicKey.algorithm as {
name: string;
namedCurve?: string;
modulusLength?: number;
};
const keyAlgoName = pubKeyAlgo.name;
if (keyAlgoName === 'RSASSA-PKCS1-v1_5' || keyAlgoName === 'RSA-PSS') {
const modulusLength = pubKeyAlgo.modulusLength ?? 0;
if (modulusLength < 2048) {
throw new CaServiceError(
`CSR RSA key is too short: ${modulusLength} bits (minimum 2048)`,
'Use an RSA key of at least 2048 bits.',
undefined,
'INVALID_CSR',
);
}
} else if (keyAlgoName === 'ECDSA') {
const namedCurve = pubKeyAlgo.namedCurve ?? '';
const allowedCurves = new Set(['P-256', 'P-384']);
if (!allowedCurves.has(namedCurve)) {
throw new CaServiceError(
`CSR EC key uses disallowed curve: ${namedCurve}`,
'Use EC P-256 or P-384. Other curves are not permitted.',
undefined,
'INVALID_CSR',
);
}
} else if (keyAlgoName === 'Ed25519') {
// Ed25519 is explicitly allowed
} else {
throw new CaServiceError(
`CSR uses unsupported key algorithm: ${keyAlgoName}`,
'Use EC (P-256/P-384), Ed25519, or RSA (≥2048 bit) keys.',
undefined,
'INVALID_CSR',
);
}
// Extract SANs if expectedSans provided
if (expectedSans && expectedSans.length > 0) {
// Get SANs from CSR extensions
const sanExtension = csr.extensions?.find(
(ext) => ext.type === '2.5.29.17', // Subject Alternative Name OID
);
const csrSans: string[] = [];
if (sanExtension) {
// Parse the raw SAN extension — store as stringified for comparison
// @peculiar/x509 exposes SANs through the parsed extension
const sanExt = sanExtension as { names?: Array<{ type: string; value: string }> };
if (sanExt.names) {
for (const name of sanExt.names) {
csrSans.push(name.value);
}
}
}
const csrSanSet = new Set(csrSans);
const expectedSanSet = new Set(expectedSans);
const missing = expectedSans.filter((s) => !csrSanSet.has(s));
const extra = csrSans.filter((s) => !expectedSanSet.has(s));
if (missing.length > 0 || extra.length > 0) {
throw new CaServiceError(
`CSR SANs do not match expected set. Missing: [${missing.join(', ')}], Extra: [${extra.join(', ')}]`,
'The CSR must include exactly the SANs specified in the issuance request.',
undefined,
'INVALID_CSR',
);
}
}
// Return the CN from the CSR subject for use as JWT sub
const cn = csr.subjectName.getField('CN')?.[0] ?? '';
return cn;
}
/**
* Submit a CSR to step-ca and return the issued certificate.
*
* Throws `CaServiceError` on any failure (network, auth, malformed input).
* Never silently swallows errors — fail-loud is a hard contract per M2-02 review.
*/
async issueCert(req: IssueCertRequestDto): Promise<IssuedCertDto> {
// Clamp TTL to 15-minute maximum (H2)
const ttl = Math.min(req.ttlSeconds ?? 300, 900);
this.logger.debug(
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${ttl}s`,
);
// Validate CSR — real cryptographic validation (H3)
const csrCn = await this.validateCsr(req.csrPem);
const ott = await this.buildOtt({
csrPem: req.csrPem,
grantId: req.grantId,
subjectUserId: req.subjectUserId,
ttlSeconds: ttl,
csrCn,
});
const signUrl = `${this.caUrl}/1.0/sign`;
const requestBody = {
csr: req.csrPem,
ott,
validity: {
duration: `${ttl}s`,
},
};
this.logger.debug(`Posting CSR to ${signUrl}`);
const response = await httpsPost(signUrl, requestBody, this.httpsAgent);
if (!response.crt) {
throw new CaServiceError(
'step-ca sign response missing the "crt" field',
'This is unexpected — the step-ca instance may be misconfigured or running an incompatible version.',
);
}
// Build certChainPem: prefer certChain array, fall back to ca field, fall back to crt alone.
let certChainPem: string;
if (response.certChain && response.certChain.length > 0) {
certChainPem = response.certChain.join('\n');
} else if (response.ca) {
certChainPem = response.crt + '\n' + response.ca;
} else {
certChainPem = response.crt;
}
const serialNumber = extractSerial(response.crt);
// CRIT-1: Verify the issued certificate contains both Mosaic OID extensions
// with the correct values. Step-CA's federation.tpl encodes each as an ASN.1
// UTF8String TLV: tag 0x0C + 1-byte length + UUID bytes. We skip 2 bytes
// (tag + length) to extract the raw UUID string.
const issuedCert = new X509Certificate(response.crt);
const decoder = new TextDecoder();
const grantIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.1');
if (!grantIdExt) {
throw new CaServiceError(
'Issued certificate is missing required Mosaic OID: mosaic_grant_id',
'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.1. Check the provisioner template configuration.',
undefined,
'OID_MISSING',
);
}
const grantIdInCert = decoder.decode(grantIdExt.value.slice(2));
if (grantIdInCert !== req.grantId) {
throw new CaServiceError(
`Issued certificate mosaic_grant_id mismatch: expected ${req.grantId}, got ${grantIdInCert}`,
'The Step-CA issued a certificate with a different grant ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
undefined,
'OID_MISMATCH',
);
}
const subjectUserIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.2');
if (!subjectUserIdExt) {
throw new CaServiceError(
'Issued certificate is missing required Mosaic OID: mosaic_subject_user_id',
'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.2. Check the provisioner template configuration.',
undefined,
'OID_MISSING',
);
}
const subjectUserIdInCert = decoder.decode(subjectUserIdExt.value.slice(2));
if (subjectUserIdInCert !== req.subjectUserId) {
throw new CaServiceError(
`Issued certificate mosaic_subject_user_id mismatch: expected ${req.subjectUserId}, got ${subjectUserIdInCert}`,
'The Step-CA issued a certificate with a different subject user ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
undefined,
'OID_MISMATCH',
);
}
this.logger.log(`Certificate issued — serial=${serialNumber} grantId=${req.grantId}`);
const result = new IssuedCertDto();
result.certPem = response.crt;
result.certChainPem = certChainPem;
result.serialNumber = serialNumber;
return result;
}
}

View File

@@ -0,0 +1,54 @@
/**
* EnrollmentController — federation enrollment HTTP layer (FED-M2-07).
*
* Routes:
* POST /api/federation/enrollment/tokens — admin creates a single-use token
* POST /api/federation/enrollment/:token — unauthenticated; token IS the auth
*/
import {
Body,
Controller,
HttpCode,
HttpStatus,
Inject,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { AdminGuard } from '../admin/admin.guard.js';
import { EnrollmentService } from './enrollment.service.js';
import { CreateEnrollmentTokenDto, RedeemEnrollmentTokenDto } from './enrollment.dto.js';
@Controller('api/federation/enrollment')
export class EnrollmentController {
constructor(@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService) {}
/**
* Admin-only: generate a single-use enrollment token for a pending grant.
* The token should be distributed out-of-band to the remote peer operator.
*
* POST /api/federation/enrollment/tokens
*/
@Post('tokens')
@UseGuards(AdminGuard)
@HttpCode(HttpStatus.CREATED)
async createToken(@Body() dto: CreateEnrollmentTokenDto) {
return this.enrollmentService.createToken(dto);
}
/**
* Unauthenticated: remote peer redeems a token by submitting its CSR.
* The token itself is the credential — no session or bearer token required.
*
* POST /api/federation/enrollment/:token
*
* Returns the signed leaf cert and full chain PEM on success.
* Returns 410 Gone if the token was already used or has expired.
*/
@Post(':token')
@HttpCode(HttpStatus.OK)
async redeem(@Param('token') token: string, @Body() dto: RedeemEnrollmentTokenDto) {
return this.enrollmentService.redeem(token, dto.csrPem);
}
}

View File

@@ -0,0 +1,35 @@
/**
* DTOs for the federation enrollment flow (FED-M2-07).
*
* CreateEnrollmentTokenDto — admin generates a single-use enrollment token
* RedeemEnrollmentTokenDto — remote peer submits CSR to redeem the token
*/
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class CreateEnrollmentTokenDto {
/** UUID of the federation grant this token will activate on redemption. */
@IsUUID()
grantId!: string;
/** UUID of the peer record that will receive the issued cert on redemption. */
@IsUUID()
peerId!: string;
/**
* Token lifetime in seconds. Default 900 (15 min). Min 60. Max 900.
* After this time the token is rejected even if unused.
*/
@IsOptional()
@IsInt()
@Min(60)
@Max(900)
ttlSeconds: number = 900;
}
export class RedeemEnrollmentTokenDto {
/** PEM-encoded PKCS#10 Certificate Signing Request from the remote peer. */
@IsString()
@IsNotEmpty()
csrPem!: string;
}

View File

@@ -0,0 +1,281 @@
/**
* EnrollmentService — single-use enrollment token lifecycle (FED-M2-07).
*
* Responsibilities:
* 1. Generate time-limited single-use enrollment tokens (admin action).
* 2. Redeem a token: validate → atomically claim token → issue cert via
* CaService → transactionally activate grant + update peer + write audit.
*
* Replay protection: the token is claimed (UPDATE WHERE used_at IS NULL) BEFORE
* cert issuance. This prevents double cert minting on concurrent requests.
* If cert issuance fails after claim, the token is consumed and the grant
* stays pending — admin must create a new grant.
*/
import {
BadRequestException,
ConflictException,
GoneException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import * as crypto from 'node:crypto';
// X509Certificate is available as a named export in Node.js ≥ 15.6
const { X509Certificate } = crypto;
import {
type Db,
and,
eq,
isNull,
sql,
federationEnrollmentTokens,
federationGrants,
federationPeers,
federationAuditLog,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { CaService } from './ca.service.js';
import { GrantsService } from './grants.service.js';
import { FederationScopeError } from './scope-schema.js';
import type { CreateEnrollmentTokenDto } from './enrollment.dto.js';
export interface EnrollmentTokenResult {
token: string;
expiresAt: string;
}
export interface RedeemResult {
certPem: string;
certChainPem: string;
}
@Injectable()
export class EnrollmentService {
private readonly logger = new Logger(EnrollmentService.name);
constructor(
@Inject(DB) private readonly db: Db,
private readonly caService: CaService,
private readonly grantsService: GrantsService,
) {}
/**
* Generate a single-use enrollment token for an admin to distribute
* out-of-band to the remote peer operator.
*/
async createToken(dto: CreateEnrollmentTokenDto): Promise<EnrollmentTokenResult> {
const ttl = Math.min(dto.ttlSeconds, 900);
// MED-3: Verify the grantId ↔ peerId binding — prevents attacker from
// cross-wiring grants to attacker-controlled peers.
const [grant] = await this.db
.select({ peerId: federationGrants.peerId })
.from(federationGrants)
.where(eq(federationGrants.id, dto.grantId))
.limit(1);
if (!grant) {
throw new NotFoundException(`Grant ${dto.grantId} not found`);
}
if (grant.peerId !== dto.peerId) {
throw new BadRequestException(`peerId does not match the grant's registered peer`);
}
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + ttl * 1000);
await this.db.insert(federationEnrollmentTokens).values({
token,
grantId: dto.grantId,
peerId: dto.peerId,
expiresAt,
});
this.logger.log(
`Enrollment token created — grantId=${dto.grantId} peerId=${dto.peerId} expiresAt=${expiresAt.toISOString()}`,
);
return { token, expiresAt: expiresAt.toISOString() };
}
/**
* Redeem an enrollment token.
*
* Full flow:
* 1. Fetch token row — NotFoundException if not found
* 2. usedAt set → GoneException (already used)
* 3. expiresAt < now → GoneException (expired)
* 4. Load grant — verify status is 'pending'
* 5. Atomically claim token (UPDATE WHERE used_at IS NULL RETURNING token)
* — if no rows returned, concurrent request won → GoneException
* 6. Issue cert via CaService (network call, outside transaction)
* — if this fails, token is consumed; grant stays pending; admin must recreate
* 7. Transaction: activate grant + update peer record + write audit log
* 8. Return { certPem, certChainPem }
*/
async redeem(token: string, csrPem: string): Promise<RedeemResult> {
// HIGH-5: Track outcome so we can write a failure audit row on any error.
let outcome: 'allowed' | 'denied' = 'denied';
// row may be undefined if the token is not found — used defensively in catch.
let row: typeof federationEnrollmentTokens.$inferSelect | undefined;
try {
// 1. Fetch token row
const [fetchedRow] = await this.db
.select()
.from(federationEnrollmentTokens)
.where(eq(federationEnrollmentTokens.token, token))
.limit(1);
if (!fetchedRow) {
throw new NotFoundException('Enrollment token not found');
}
row = fetchedRow;
// 2. Already used?
if (row.usedAt !== null) {
throw new GoneException('Enrollment token has already been used');
}
// 3. Expired?
if (row.expiresAt < new Date()) {
throw new GoneException('Enrollment token has expired');
}
// 4. Load grant and verify it is still pending
let grant;
try {
grant = await this.grantsService.getGrant(row.grantId);
} catch (err) {
if (err instanceof FederationScopeError) {
throw new BadRequestException(err.message);
}
throw err;
}
if (grant.status !== 'pending') {
throw new GoneException(
`Grant ${row.grantId} is no longer pending (status: ${grant.status})`,
);
}
// 5. Atomically claim the token BEFORE cert issuance to prevent double-minting.
// WHERE used_at IS NULL ensures only one concurrent request wins.
// Using .returning() works on both node-postgres and PGlite without rowCount inspection.
const claimed = await this.db
.update(federationEnrollmentTokens)
.set({ usedAt: sql`NOW()` })
.where(
and(
eq(federationEnrollmentTokens.token, token),
isNull(federationEnrollmentTokens.usedAt),
),
)
.returning({ token: federationEnrollmentTokens.token });
if (claimed.length === 0) {
throw new GoneException('Enrollment token has already been used (concurrent request)');
}
// 6. Issue certificate via CaService (network call — outside any transaction).
// If this throws, the token is already consumed. The grant stays pending.
// Admin must revoke the grant and create a new one.
let issued;
try {
issued = await this.caService.issueCert({
csrPem,
grantId: row.grantId,
subjectUserId: grant.subjectUserId,
ttlSeconds: 300,
});
} catch (err) {
// HIGH-4: Log only the first 8 hex chars of the token for correlation — never log the full token.
this.logger.error(
`issueCert failed after token ${token.slice(0, 8)}... was claimed — grant ${row.grantId} is stranded pending`,
err instanceof Error ? err.stack : String(err),
);
if (err instanceof FederationScopeError) {
throw new BadRequestException((err as Error).message);
}
throw err;
}
// 7. Atomically activate grant, update peer record, and write audit log.
const certNotAfter = this.extractCertNotAfter(issued.certPem);
await this.db.transaction(async (tx) => {
// CRIT-2: Guard activation with WHERE status='pending' to prevent double-activation.
const [activated] = await tx
.update(federationGrants)
.set({ status: 'active' })
.where(and(eq(federationGrants.id, row!.grantId), eq(federationGrants.status, 'pending')))
.returning({ id: federationGrants.id });
if (!activated) {
throw new ConflictException(
`Grant ${row!.grantId} is no longer pending — cannot activate`,
);
}
// CRIT-2: Guard peer update with WHERE state='pending'.
await tx
.update(federationPeers)
.set({
certPem: issued.certPem,
certSerial: issued.serialNumber,
certNotAfter,
state: 'active',
})
.where(and(eq(federationPeers.id, row!.peerId), eq(federationPeers.state, 'pending')));
await tx.insert(federationAuditLog).values({
requestId: crypto.randomUUID(),
peerId: row!.peerId,
grantId: row!.grantId,
verb: 'enrollment',
resource: 'federation_grant',
statusCode: 200,
outcome: 'allowed',
});
});
this.logger.log(
`Enrollment complete — peerId=${row.peerId} grantId=${row.grantId} serial=${issued.serialNumber}`,
);
outcome = 'allowed';
// 8. Return cert material
return {
certPem: issued.certPem,
certChainPem: issued.certChainPem,
};
} catch (err) {
// HIGH-5: Best-effort audit write on failure — do not let this throw.
if (outcome === 'denied') {
await this.db
.insert(federationAuditLog)
.values({
requestId: crypto.randomUUID(),
peerId: row?.peerId ?? null,
grantId: row?.grantId ?? null,
verb: 'enrollment',
resource: 'federation_grant',
statusCode:
err instanceof GoneException ? 410 : err instanceof NotFoundException ? 404 : 500,
outcome: 'denied',
})
.catch(() => {});
}
throw err;
}
}
/**
* Extract the notAfter date from a PEM certificate.
* HIGH-2: No silent fallback — a cert that cannot be parsed should fail loud.
*/
private extractCertNotAfter(certPem: string): Date {
const cert = new X509Certificate(certPem);
return new Date(cert.validTo);
}
}

View File

@@ -0,0 +1,39 @@
/**
* DTOs for the federation admin controller (FED-M2-08).
*/
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min } from 'class-validator';
export class CreatePeerKeypairDto {
@IsString()
@IsNotEmpty()
commonName!: string;
@IsString()
@IsNotEmpty()
displayName!: string;
@IsOptional()
@IsUrl()
endpointUrl?: string;
}
export class StorePeerCertDto {
@IsString()
@IsNotEmpty()
certPem!: string;
}
export class GenerateEnrollmentTokenDto {
@IsOptional()
@IsInt()
@Min(60)
@Max(900)
ttlSeconds: number = 900;
}
export class RevokeGrantBodyDto {
@IsOptional()
@IsString()
reason?: string;
}

View File

@@ -0,0 +1,266 @@
/**
* FederationController — admin REST API for federation management (FED-M2-08).
*
* Routes (all under /api/admin/federation, all require AdminGuard):
*
* Grant management:
* POST /api/admin/federation/grants
* GET /api/admin/federation/grants
* GET /api/admin/federation/grants/:id
* PATCH /api/admin/federation/grants/:id/revoke
* POST /api/admin/federation/grants/:id/tokens
*
* Peer management:
* GET /api/admin/federation/peers
* POST /api/admin/federation/peers/keypair
* PATCH /api/admin/federation/peers/:id/cert
*
* NOTE: The enrollment REDEMPTION endpoint (POST /api/federation/enrollment/:token)
* is handled by EnrollmentController — not duplicated here.
*/
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { webcrypto } from 'node:crypto';
import { X509Certificate } from 'node:crypto';
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
import { type Db, eq, federationPeers } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { AdminGuard } from '../admin/admin.guard.js';
import { GrantsService } from './grants.service.js';
import { EnrollmentService } from './enrollment.service.js';
import { sealClientKey } from './peer-key.util.js';
import { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
import {
CreatePeerKeypairDto,
GenerateEnrollmentTokenDto,
RevokeGrantBodyDto,
StorePeerCertDto,
} from './federation-admin.dto.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Convert an ArrayBuffer to a Base64 string (for PEM encoding).
*/
function arrayBufferToBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = '';
for (const b of bytes) {
binary += String.fromCharCode(b);
}
return Buffer.from(binary, 'binary').toString('base64');
}
/**
* Wrap a Base64 string in PEM armour.
*/
function toPem(label: string, b64: string): string {
const lines = b64.match(/.{1,64}/g) ?? [];
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
@Controller('api/admin/federation')
@UseGuards(AdminGuard)
export class FederationController {
constructor(
@Inject(DB) private readonly db: Db,
@Inject(GrantsService) private readonly grantsService: GrantsService,
@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService,
) {}
// ─── Grant management ────────────────────────────────────────────────────
/**
* POST /api/admin/federation/grants
* Create a new grant in pending state.
*/
@Post('grants')
@HttpCode(HttpStatus.CREATED)
async createGrant(@Body() body: CreateGrantDto) {
return this.grantsService.createGrant(body);
}
/**
* GET /api/admin/federation/grants
* List grants with optional filters.
*/
@Get('grants')
async listGrants(@Query() query: ListGrantsDto) {
return this.grantsService.listGrants(query);
}
/**
* GET /api/admin/federation/grants/:id
* Get a single grant by ID.
*/
@Get('grants/:id')
async getGrant(@Param('id') id: string) {
return this.grantsService.getGrant(id);
}
/**
* PATCH /api/admin/federation/grants/:id/revoke
* Revoke an active grant.
*/
@Patch('grants/:id/revoke')
async revokeGrant(@Param('id') id: string, @Body() body: RevokeGrantBodyDto) {
return this.grantsService.revokeGrant(id, body.reason);
}
/**
* POST /api/admin/federation/grants/:id/tokens
* Generate a single-use enrollment token for a pending grant.
* Returns the token plus an enrollmentUrl the operator shares out-of-band.
*/
@Post('grants/:id/tokens')
@HttpCode(HttpStatus.CREATED)
async generateToken(@Param('id') id: string, @Body() body: GenerateEnrollmentTokenDto) {
const grant = await this.grantsService.getGrant(id);
const result = await this.enrollmentService.createToken({
grantId: id,
peerId: grant.peerId,
ttlSeconds: body.ttlSeconds ?? 900,
});
const baseUrl = process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242';
const enrollmentUrl = `${baseUrl}/api/federation/enrollment/${result.token}`;
return {
token: result.token,
expiresAt: result.expiresAt,
enrollmentUrl,
};
}
// ─── Peer management ─────────────────────────────────────────────────────
/**
* GET /api/admin/federation/peers
* List all federation peer rows.
*/
@Get('peers')
async listPeers() {
return this.db.select().from(federationPeers).orderBy(federationPeers.commonName);
}
/**
* POST /api/admin/federation/peers/keypair
* Generate a new peer entry with EC P-256 key pair and a PKCS#10 CSR.
*
* Flow:
* 1. Generate EC P-256 key pair via webcrypto
* 2. Generate a self-signed CSR via @peculiar/x509
* 3. Export private key as PEM
* 4. sealClientKey(privatePem) → sealed blob
* 5. Insert pending peer row
* 6. Return { peerId, csrPem }
*/
@Post('peers/keypair')
@HttpCode(HttpStatus.CREATED)
async createPeerKeypair(@Body() body: CreatePeerKeypairDto) {
// 1. Generate EC P-256 key pair via Web Crypto
const keyPair = await webcrypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true, // extractable
['sign', 'verify'],
);
// 2. Generate PKCS#10 CSR
const csr = await Pkcs10CertificateRequestGenerator.create({
name: `CN=${body.commonName}`,
keys: keyPair,
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
});
const csrPem = csr.toString('pem');
// 3. Export private key as PKCS#8 PEM
const pkcs8Der = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey);
const privatePem = toPem('PRIVATE KEY', arrayBufferToBase64(pkcs8Der));
// 4. Seal the private key
const sealed = sealClientKey(privatePem);
// 5. Insert pending peer row
const [peer] = await this.db
.insert(federationPeers)
.values({
commonName: body.commonName,
displayName: body.displayName,
certPem: '',
certSerial: 'pending',
certNotAfter: new Date(0),
clientKeyPem: sealed,
state: 'pending',
endpointUrl: body.endpointUrl,
})
.returning();
return {
peerId: peer!.id,
csrPem,
};
}
/**
* PATCH /api/admin/federation/peers/:id/cert
* Store a signed certificate after enrollment completes.
*
* Flow:
* 1. Parse the cert to extract serial and notAfter
* 2. Update the peer row with cert data + state='active'
* 3. Return the updated peer row
*/
@Patch('peers/:id/cert')
async storePeerCert(@Param('id') id: string, @Body() body: StorePeerCertDto) {
// Ensure peer exists
const [existing] = await this.db
.select({ id: federationPeers.id })
.from(federationPeers)
.where(eq(federationPeers.id, id))
.limit(1);
if (!existing) {
throw new NotFoundException(`Peer ${id} not found`);
}
// 1. Parse cert
const x509 = new X509Certificate(body.certPem);
const certSerial = x509.serialNumber;
const certNotAfter = new Date(x509.validTo);
// 2. Update peer
const [updated] = await this.db
.update(federationPeers)
.set({
certPem: body.certPem,
certSerial,
certNotAfter,
state: 'active',
})
.where(eq(federationPeers.id, id))
.returning();
return updated;
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AdminGuard } from '../admin/admin.guard.js';
import { CaService } from './ca.service.js';
import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js';
import { GrantsService } from './grants.service.js';
@Module({
controllers: [EnrollmentController, FederationController],
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
exports: [CaService, EnrollmentService, GrantsService],
})
export class FederationModule {}

View File

@@ -0,0 +1,36 @@
import { IsDateString, IsIn, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateGrantDto {
@IsUUID()
peerId!: string;
@IsUUID()
subjectUserId!: string;
@IsObject()
scope!: Record<string, unknown>;
@IsOptional()
@IsDateString()
expiresAt?: string;
}
export class ListGrantsDto {
@IsOptional()
@IsUUID()
peerId?: string;
@IsOptional()
@IsUUID()
subjectUserId?: string;
@IsOptional()
@IsIn(['pending', 'active', 'revoked', 'expired'])
status?: 'pending' | 'active' | 'revoked' | 'expired';
}
export class RevokeGrantDto {
@IsOptional()
@IsString()
reason?: string;
}

View File

@@ -0,0 +1,161 @@
/**
* Federation grants service — CRUD + status transitions (FED-M2-06).
*
* Business logic only. CSR/cert work is handled by M2-07.
*
* Status lifecycle:
* pending → active (activateGrant, called by M2-07 enrollment controller after cert signed)
* active → revoked (revokeGrant)
* active → expired (expireGrant, called by M6 scheduler)
*/
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { type Db, and, eq, federationGrants } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { parseFederationScope } from './scope-schema.js';
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
export type Grant = typeof federationGrants.$inferSelect;
@Injectable()
export class GrantsService {
constructor(@Inject(DB) private readonly db: Db) {}
/**
* Create a new grant in `pending` state.
* Validates the scope against the federation scope JSON schema before inserting.
*/
async createGrant(dto: CreateGrantDto): Promise<Grant> {
// Throws FederationScopeError (a plain Error subclass) on invalid scope.
parseFederationScope(dto.scope);
const [grant] = await this.db
.insert(federationGrants)
.values({
peerId: dto.peerId,
subjectUserId: dto.subjectUserId,
scope: dto.scope,
status: 'pending',
expiresAt: dto.expiresAt != null ? new Date(dto.expiresAt) : null,
})
.returning();
return grant!;
}
/**
* Fetch a single grant by ID. Throws NotFoundException if not found.
*/
async getGrant(id: string): Promise<Grant> {
const [grant] = await this.db
.select()
.from(federationGrants)
.where(eq(federationGrants.id, id))
.limit(1);
if (!grant) {
throw new NotFoundException(`Grant ${id} not found`);
}
return grant;
}
/**
* List grants with optional filters for peerId, subjectUserId, and status.
*/
async listGrants(filters: ListGrantsDto): Promise<Grant[]> {
const conditions = [];
if (filters.peerId != null) {
conditions.push(eq(federationGrants.peerId, filters.peerId));
}
if (filters.subjectUserId != null) {
conditions.push(eq(federationGrants.subjectUserId, filters.subjectUserId));
}
if (filters.status != null) {
conditions.push(eq(federationGrants.status, filters.status));
}
if (conditions.length === 0) {
return this.db.select().from(federationGrants);
}
return this.db
.select()
.from(federationGrants)
.where(and(...conditions));
}
/**
* Transition a grant from `pending` → `active`.
* Called by M2-07 enrollment controller after cert is signed.
* Throws ConflictException if the grant is not in `pending` state.
*/
async activateGrant(id: string): Promise<Grant> {
const grant = await this.getGrant(id);
if (grant.status !== 'pending') {
throw new ConflictException(
`Grant ${id} cannot be activated: expected status 'pending', got '${grant.status}'`,
);
}
const [updated] = await this.db
.update(federationGrants)
.set({ status: 'active' })
.where(eq(federationGrants.id, id))
.returning();
return updated!;
}
/**
* Transition a grant from `active` → `revoked`.
* Sets revokedAt and optionally revokedReason.
* Throws ConflictException if the grant is not in `active` state.
*/
async revokeGrant(id: string, reason?: string): Promise<Grant> {
const grant = await this.getGrant(id);
if (grant.status !== 'active') {
throw new ConflictException(
`Grant ${id} cannot be revoked: expected status 'active', got '${grant.status}'`,
);
}
const [updated] = await this.db
.update(federationGrants)
.set({
status: 'revoked',
revokedAt: new Date(),
revokedReason: reason ?? null,
})
.where(eq(federationGrants.id, id))
.returning();
return updated!;
}
/**
* Transition a grant from `active` → `expired`.
* Intended for use by the M6 scheduler.
* Throws ConflictException if the grant is not in `active` state.
*/
async expireGrant(id: string): Promise<Grant> {
const grant = await this.getGrant(id);
if (grant.status !== 'active') {
throw new ConflictException(
`Grant ${id} cannot be expired: expected status 'active', got '${grant.status}'`,
);
}
const [updated] = await this.db
.update(federationGrants)
.set({ status: 'expired' })
.where(eq(federationGrants.id, id))
.returning();
return updated!;
}
}

View File

@@ -0,0 +1,9 @@
import { seal, unseal } from '@mosaicstack/auth';
export function sealClientKey(privateKeyPem: string): string {
return seal(privateKeyPem);
}
export function unsealClientKey(sealedKey: string): string {
return unseal(sealedKey);
}

View File

@@ -0,0 +1,187 @@
/**
* Unit tests for FederationScopeSchema and parseFederationScope.
*
* Coverage:
* - Valid: minimal scope
* - Valid: full PRD §8.1 example
* - Valid: resources + excluded_resources (no overlap)
* - Invalid: empty resources
* - Invalid: unknown resource value
* - Invalid: resources / excluded_resources intersection
* - Invalid: filter key not in resources
* - Invalid: max_rows_per_query = 0
* - Invalid: max_rows_per_query = 10001
* - Invalid: not an object / null
* - Defaults: include_personal defaults to true; excluded_resources defaults to []
* - Sentinel: console.warn fires for sensitive resources
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
parseFederationScope,
FederationScopeError,
FederationScopeSchema,
} from './scope-schema.js';
afterEach(() => {
vi.restoreAllMocks();
});
describe('parseFederationScope — valid inputs', () => {
it('accepts a minimal scope (resources + max_rows_per_query only)', () => {
const scope = parseFederationScope({
resources: ['tasks'],
max_rows_per_query: 100,
});
expect(scope.resources).toEqual(['tasks']);
expect(scope.max_rows_per_query).toBe(100);
expect(scope.excluded_resources).toEqual([]);
expect(scope.filters).toBeUndefined();
});
it('accepts the full PRD §8.1 example', () => {
const scope = parseFederationScope({
resources: ['tasks', 'notes', 'memory'],
filters: {
tasks: { include_teams: ['team_uuid_1', 'team_uuid_2'], include_personal: true },
notes: { include_personal: true, include_teams: [] },
memory: { include_personal: true },
},
excluded_resources: ['credentials', 'api_keys'],
max_rows_per_query: 500,
});
expect(scope.resources).toEqual(['tasks', 'notes', 'memory']);
expect(scope.excluded_resources).toEqual(['credentials', 'api_keys']);
expect(scope.filters?.tasks?.include_teams).toEqual(['team_uuid_1', 'team_uuid_2']);
expect(scope.max_rows_per_query).toBe(500);
});
it('accepts a scope with excluded_resources and no filter overlap', () => {
const scope = parseFederationScope({
resources: ['tasks', 'notes'],
excluded_resources: ['memory'],
max_rows_per_query: 250,
});
expect(scope.resources).toEqual(['tasks', 'notes']);
expect(scope.excluded_resources).toEqual(['memory']);
});
});
describe('parseFederationScope — defaults', () => {
it('defaults excluded_resources to []', () => {
const scope = parseFederationScope({ resources: ['tasks'], max_rows_per_query: 1 });
expect(scope.excluded_resources).toEqual([]);
});
it('defaults include_personal to true when filter is provided without it', () => {
const scope = parseFederationScope({
resources: ['tasks'],
filters: { tasks: { include_teams: ['t1'] } },
max_rows_per_query: 10,
});
expect(scope.filters?.tasks?.include_personal).toBe(true);
});
});
describe('parseFederationScope — invalid inputs', () => {
it('throws FederationScopeError for empty resources array', () => {
expect(() => parseFederationScope({ resources: [], max_rows_per_query: 100 })).toThrow(
FederationScopeError,
);
});
it('throws for unknown resource value in resources', () => {
expect(() =>
parseFederationScope({ resources: ['unknown_resource'], max_rows_per_query: 100 }),
).toThrow(FederationScopeError);
});
it('throws when resources and excluded_resources intersect', () => {
expect(() =>
parseFederationScope({
resources: ['tasks', 'memory'],
excluded_resources: ['memory'],
max_rows_per_query: 100,
}),
).toThrow(FederationScopeError);
});
it('throws when filters references a resource not in resources', () => {
expect(() =>
parseFederationScope({
resources: ['tasks'],
filters: { notes: { include_personal: true } },
max_rows_per_query: 100,
}),
).toThrow(FederationScopeError);
});
it('throws for max_rows_per_query = 0', () => {
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 0 })).toThrow(
FederationScopeError,
);
});
it('throws for max_rows_per_query = 10001', () => {
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 10001 })).toThrow(
FederationScopeError,
);
});
it('throws for null input', () => {
expect(() => parseFederationScope(null)).toThrow(FederationScopeError);
});
it('throws for non-object input (string)', () => {
expect(() => parseFederationScope('not-an-object')).toThrow(FederationScopeError);
});
});
describe('parseFederationScope — sentinel warning', () => {
it('emits console.warn when resources includes "credentials"', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
parseFederationScope({
resources: ['tasks', 'credentials'],
max_rows_per_query: 100,
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'[FederationScope] WARNING: scope grants sensitive resource "credentials"',
),
);
});
it('emits console.warn when resources includes "api_keys"', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
parseFederationScope({
resources: ['tasks', 'api_keys'],
max_rows_per_query: 100,
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'[FederationScope] WARNING: scope grants sensitive resource "api_keys"',
),
);
});
it('does NOT emit console.warn for non-sensitive resources', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
parseFederationScope({ resources: ['tasks', 'notes', 'memory'], max_rows_per_query: 100 });
expect(warnSpy).not.toHaveBeenCalled();
});
});
describe('FederationScopeSchema — boundary values', () => {
it('accepts max_rows_per_query = 1 (lower bound)', () => {
const result = FederationScopeSchema.safeParse({ resources: ['tasks'], max_rows_per_query: 1 });
expect(result.success).toBe(true);
});
it('accepts max_rows_per_query = 10000 (upper bound)', () => {
const result = FederationScopeSchema.safeParse({
resources: ['tasks'],
max_rows_per_query: 10000,
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,147 @@
/**
* Federation grant scope schema and validator.
*
* Source of truth: docs/federation/PRD.md §8.1
*
* This module is intentionally pure — no DB, no NestJS, no CA wiring.
* It is reusable from grant CRUD (M2-06) and scope enforcement (M3+).
*/
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Allowlist of federation resources (canonical — M3+ will extend this list)
// ---------------------------------------------------------------------------
export const FEDERATION_RESOURCE_VALUES = [
'tasks',
'notes',
'memory',
'credentials',
'api_keys',
] as const;
export type FederationResource = (typeof FEDERATION_RESOURCE_VALUES)[number];
/**
* Sensitive resources require explicit admin approval (PRD §8.4).
* The parser warns when these appear in `resources`; M2-06 grant CRUD
* will add a hard gate on top of this warning.
*/
const SENSITIVE_RESOURCES: ReadonlySet<FederationResource> = new Set(['credentials', 'api_keys']);
// ---------------------------------------------------------------------------
// Sub-schemas
// ---------------------------------------------------------------------------
const ResourceArraySchema = z
.array(z.enum(FEDERATION_RESOURCE_VALUES))
.nonempty({ message: 'resources must contain at least one value' })
.refine((arr) => new Set(arr).size === arr.length, {
message: 'resources must not contain duplicate values',
});
const ResourceFilterSchema = z.object({
include_teams: z.array(z.string()).optional(),
include_personal: z.boolean().default(true),
});
// ---------------------------------------------------------------------------
// Top-level schema
// ---------------------------------------------------------------------------
export const FederationScopeSchema = z
.object({
resources: ResourceArraySchema,
excluded_resources: z
.array(z.enum(FEDERATION_RESOURCE_VALUES))
.default([])
.refine((arr) => new Set(arr).size === arr.length, {
message: 'excluded_resources must not contain duplicate values',
}),
filters: z.record(z.string(), ResourceFilterSchema).optional(),
max_rows_per_query: z
.number()
.int({ message: 'max_rows_per_query must be an integer' })
.min(1, { message: 'max_rows_per_query must be at least 1' })
.max(10000, { message: 'max_rows_per_query must be at most 10000' }),
})
.superRefine((data, ctx) => {
const resourceSet = new Set(data.resources);
// Intersection guard: a resource cannot be both granted and excluded
for (const r of data.excluded_resources) {
if (resourceSet.has(r)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Resource "${r}" appears in both resources and excluded_resources`,
path: ['excluded_resources'],
});
}
}
// Filter keys must be a subset of resources
if (data.filters) {
for (const key of Object.keys(data.filters)) {
if (!resourceSet.has(key as FederationResource)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `filters key "${key}" references a resource not present in resources`,
path: ['filters', key],
});
}
}
}
});
export type FederationScope = z.infer<typeof FederationScopeSchema>;
// ---------------------------------------------------------------------------
// Error class
// ---------------------------------------------------------------------------
export class FederationScopeError extends Error {
constructor(message: string) {
super(message);
this.name = 'FederationScopeError';
}
}
// ---------------------------------------------------------------------------
// Typed parser
// ---------------------------------------------------------------------------
/**
* Parse and validate an unknown value as a FederationScope.
*
* Throws `FederationScopeError` with aggregated Zod issues on failure.
*
* Emits `console.warn` when sensitive resources (`credentials`, `api_keys`)
* are present in `resources` — per PRD §8.4, these require explicit admin
* approval. M2-06 grant CRUD will add a hard gate on top of this warning.
*/
export function parseFederationScope(input: unknown): FederationScope {
const result = FederationScopeSchema.safeParse(input);
if (!result.success) {
const issues = result.error.issues
.map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`)
.join('\n');
throw new FederationScopeError(`Invalid federation scope:\n${issues}`);
}
const scope = result.data;
// Sentinel warning for sensitive resources (PRD §8.4)
for (const resource of scope.resources) {
if (SENSITIVE_RESOURCES.has(resource)) {
console.warn(
`[FederationScope] WARNING: scope grants sensitive resource "${resource}". Per PRD §8.4 this requires explicit admin approval and is logged.`,
);
}
}
return scope;
}

View File

@@ -1,5 +1,5 @@
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common'; import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaic/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import { SessionGCService } from './session-gc.service.js'; import { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js'; import { REDIS } from './gc.tokens.js';

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import type { QueueHandle } from '@mosaic/queue'; import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaic/log'; import type { LogService } from '@mosaicstack/log';
import { SessionGCService } from './session-gc.service.js'; import { SessionGCService } from './session-gc.service.js';
type MockRedis = { type MockRedis = {

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common'; import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import type { QueueHandle } from '@mosaic/queue'; import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaic/log'; import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js'; import { LOG_SERVICE } from '../log/log.tokens.js';
import { REDIS } from './gc.tokens.js'; import { REDIS } from './gc.tokens.js';

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common';
import type { LogService } from '@mosaic/log'; import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from './log.tokens.js'; import { LOG_SERVICE } from './log.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import type { IngestLogDto, QueryLogsDto } from './log.dto.js'; import type { IngestLogDto, QueryLogsDto } from './log.dto.js';

View File

@@ -1,6 +1,6 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { createLogService, type LogService } from '@mosaic/log'; import { createLogService, type LogService } from '@mosaicstack/log';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaicstack/db';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { LOG_SERVICE } from './log.tokens.js'; import { LOG_SERVICE } from './log.tokens.js';
import { LogController } from './log.controller.js'; import { LogController } from './log.controller.js';

View File

@@ -1,11 +1,11 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import type { LogService } from '@mosaic/log'; import type { LogService } from '@mosaicstack/log';
import type { Memory } from '@mosaic/memory'; import type { Memory } from '@mosaicstack/memory';
import { LOG_SERVICE } from './log.tokens.js'; import { LOG_SERVICE } from './log.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js';
import { EmbeddingService } from '../memory/embedding.service.js'; import { EmbeddingService } from '../memory/embedding.service.js';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaicstack/db';
import { sql, summarizationJobs } from '@mosaic/db'; import { sql, summarizationJobs } from '@mosaicstack/db';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
const SUMMARIZATION_PROMPT = `You are a knowledge extraction assistant. Given the following agent interaction logs, extract the key decisions, learnings, and patterns. Output a concise summary (2-4 sentences) that captures the most important information for future reference. Focus on actionable insights, not raw events. const SUMMARIZATION_PROMPT = `You are a knowledge extraction assistant. Given the following agent interaction logs, extract the key decisions, learnings, and patterns. Output a concise summary (2-4 sentences) that captures the most important information for future reference. Focus on actionable insights, not raw events.

View File

@@ -19,11 +19,13 @@ import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import helmet from '@fastify/helmet'; import helmet from '@fastify/helmet';
import { listSsoStartupWarnings } from '@mosaic/auth'; import { listSsoStartupWarnings } from '@mosaicstack/auth';
import { loadConfig } from '@mosaicstack/config';
import { AppModule } from './app.module.js'; import { AppModule } from './app.module.js';
import { mountAuthHandler } from './auth/auth.controller.js'; import { mountAuthHandler } from './auth/auth.controller.js';
import { mountMcpHandler } from './mcp/mcp.controller.js'; import { mountMcpHandler } from './mcp/mcp.controller.js';
import { McpService } from './mcp/mcp.service.js'; import { McpService } from './mcp/mcp.service.js';
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap'); const logger = new Logger('Bootstrap');
@@ -32,6 +34,20 @@ async function bootstrap(): Promise<void> {
throw new Error('BETTER_AUTH_SECRET is required'); throw new Error('BETTER_AUTH_SECRET is required');
} }
// Pre-flight: assert all external services required by the configured tier
// are reachable. Runs before NestFactory.create() so failures are visible
// immediately with actionable remediation hints.
const mosaicConfig = loadConfig();
try {
await detectAndAssertTier(mosaicConfig);
} catch (err) {
if (err instanceof TierDetectionError) {
logger.error(`Tier detection failed: ${err.message}`);
logger.error(`Remediation: ${err.remediation}`);
}
throw err;
}
for (const warning of listSsoStartupWarnings()) { for (const warning of listSsoStartupWarnings()) {
logger.warn(warning); logger.warn(warning);
} }

View File

@@ -1,7 +1,7 @@
import type { IncomingMessage, ServerResponse } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { fromNodeHeaders } from 'better-auth/node'; import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaic/auth'; import type { Auth } from '@mosaicstack/auth';
import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import type { McpService } from './mcp.service.js'; import type { McpService } from './mcp.service.js';
import { AUTH } from '../auth/auth.tokens.js'; import { AUTH } from '../auth/auth.tokens.js';

View File

@@ -3,8 +3,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { z } from 'zod'; import { z } from 'zod';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import type { Memory } from '@mosaic/memory'; import type { Memory } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js';
import { EmbeddingService } from '../memory/embedding.service.js'; import { EmbeddingService } from '../memory/embedding.service.js';

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import type { EmbeddingProvider } from '@mosaic/memory'; import type { EmbeddingProvider } from '@mosaicstack/memory';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Environment-driven configuration // Environment-driven configuration

View File

@@ -12,7 +12,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Memory } from '@mosaic/memory'; import type { Memory } from '@mosaicstack/memory';
import { MEMORY } from './memory.tokens.js'; import { MEMORY } from './memory.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';

View File

@@ -5,10 +5,10 @@ import {
createMemoryAdapter, createMemoryAdapter,
type MemoryAdapter, type MemoryAdapter,
type MemoryConfig, type MemoryConfig,
} from '@mosaic/memory'; } from '@mosaicstack/memory';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaicstack/db';
import type { StorageAdapter } from '@mosaic/storage'; import type { StorageAdapter } from '@mosaicstack/storage';
import type { MosaicConfig } from '@mosaic/config'; import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js'; import { MOSAIC_CONFIG } from '../config/config.module.js';
import { DB, STORAGE_ADAPTER } from '../database/database.module.js'; import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
import { MEMORY } from './memory.tokens.js'; import { MEMORY } from './memory.tokens.js';

View File

@@ -12,7 +12,7 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';

View File

@@ -6,8 +6,8 @@ import {
type OnModuleDestroy, type OnModuleDestroy,
type OnModuleInit, type OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import { DiscordPlugin } from '@mosaic/discord-plugin'; import { DiscordPlugin } from '@mosaicstack/discord-plugin';
import { TelegramPlugin } from '@mosaic/telegram-plugin'; import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
import { PluginService } from './plugin.service.js'; import { PluginService } from './plugin.service.js';
import type { IChannelPlugin } from './plugin.interface.js'; import type { IChannelPlugin } from './plugin.interface.js';
import { PLUGIN_REGISTRY } from './plugin.tokens.js'; import { PLUGIN_REGISTRY } from './plugin.tokens.js';

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js'; import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js';
import type { Db } from '@mosaic/db'; import type { Db } from '@mosaicstack/db';
/** /**
* Build a mock Drizzle DB where the select chain supports: * Build a mock Drizzle DB where the select chain supports:

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, and, sql, type Db, preferences as preferencesTable } from '@mosaic/db'; import { eq, and, sql, type Db, preferences as preferencesTable } from '@mosaicstack/db';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
export const PLATFORM_DEFAULTS: Record<string, unknown> = { export const PLATFORM_DEFAULTS: Record<string, unknown> = {

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaic/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`; const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) => const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>

View File

@@ -13,7 +13,7 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';

View File

@@ -1,6 +1,6 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { createQueueAdapter, type QueueAdapter } from '@mosaic/queue'; import { createQueueAdapter, type QueueAdapter } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaic/config'; import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js'; import { MOSAIC_CONFIG } from '../config/config.module.js';
import { QueueService } from './queue.service.js'; import { QueueService } from './queue.service.js';

View File

@@ -7,7 +7,7 @@ import {
type OnModuleDestroy, type OnModuleDestroy,
} from '@nestjs/common'; } from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq'; import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaic/log'; import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js'; import { LOG_SERVICE } from '../log/log.tokens.js';
import type { JobDto, JobStatus } from './queue-admin.dto.js'; import type { JobDto, JobStatus } from './queue-admin.dto.js';

View File

@@ -1,5 +1,5 @@
import { Controller, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common'; import { Controller, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common';
import type { SystemReloadPayload } from '@mosaic/types'; import type { SystemReloadPayload } from '@mosaicstack/types';
import { AdminGuard } from '../admin/admin.guard.js'; import { AdminGuard } from '../admin/admin.guard.js';
import { ChatGateway } from '../chat/chat.gateway.js'; import { ChatGateway } from '../chat/chat.gateway.js';
import { ReloadService } from './reload.service.js'; import { ReloadService } from './reload.service.js';

View File

@@ -5,7 +5,7 @@ import {
type OnApplicationBootstrap, type OnApplicationBootstrap,
type OnApplicationShutdown, type OnApplicationShutdown,
} from '@nestjs/common'; } from '@nestjs/common';
import type { SystemReloadPayload } from '@mosaic/types'; import type { SystemReloadPayload } from '@mosaicstack/types';
import { CommandRegistryService } from '../commands/command-registry.service.js'; import { CommandRegistryService } from '../commands/command-registry.service.js';
import { isMosaicPlugin } from './mosaic-plugin.interface.js'; import { isMosaicPlugin } from './mosaic-plugin.interface.js';

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { eq, type Db, skills } from '@mosaic/db'; import { eq, type Db, skills } from '@mosaicstack/db';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
type Skill = typeof skills.$inferSelect; type Skill = typeof skills.$inferSelect;

View File

@@ -14,7 +14,7 @@ import {
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import type { Brain } from '@mosaic/brain'; import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { PluginService } from '../plugin/plugin.service.js'; import { PluginService } from '../plugin/plugin.service.js';
import { WorkspaceService } from './workspace.service.js'; import { WorkspaceService } from './workspace.service.js';

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