Compare commits
96 Commits
fix/update
...
feat/feder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7524d6e919 | ||
|
|
9f5c28c0ce | ||
| bf082d95a0 | |||
| bb24292cf7 | |||
| f2cda52e1a | |||
| 7d7cf012f0 | |||
| c56dda74aa | |||
| 9f1a08185e | |||
| d2e408656b | |||
| 54c278b871 | |||
| 4dbd429203 | |||
| b985d7bfe2 | |||
| 45e8f02c91 | |||
| 54c422ab06 | |||
|
|
b9fb8aab57 | ||
| 78841f228a | |||
| dc4afee848 | |||
| 1e2b8ac8de | |||
| 15d849c166 | |||
| 78251d4af8 | |||
| 1a4b1ebbf1 | |||
| ccad30dd27 | |||
| 4c2b177eab | |||
| 58169f9979 | |||
| 51402bdb6d | |||
| 9c89c32684 | |||
| 8aabb8c5b2 | |||
| 66512550df | |||
| 46dd799548 | |||
| 5f03c05523 | |||
| c3f810bbd1 | |||
| b2cbf898d7 | |||
| b2cec8c6ba | |||
| 81c1775a03 | |||
| f64ec12f39 | |||
| 026382325c | |||
| 1bfd8570d6 | |||
| 312acd8bad | |||
| d08b969918 | |||
| 051de0d8a9 | |||
| bd76df1a50 | |||
| 62b2ce2da1 | |||
| 172bacb30f | |||
| 43667d7349 | |||
| 783884376c | |||
| c08aa6fa46 | |||
| 0ae932ab34 | |||
| a8cd52e88c | |||
| a4c94d9a90 | |||
| cee838d22e | |||
| 732f8a49cf | |||
| be917e2496 | |||
| cd8b1f666d | |||
| 8fa5995bde | |||
| 25cada7735 | |||
| be6553101c | |||
| 417805f330 | |||
| 2472ce52e8 | |||
| 597eb232d7 | |||
| afe997db82 | |||
| b9d464de61 | |||
| 872c124581 | |||
| a531029c5b | |||
| 35ab619bd0 | |||
| 831193cdd8 | |||
| df460d5a49 | |||
| 119ff0eb1b | |||
| 3abd63ea5c | |||
| 641e4604d5 | |||
|
|
9b5ecc0171 | ||
|
|
a00325da0e | ||
| 4ebce3422d | |||
| 751e0ee330 | |||
| 54b2920ef3 | |||
| 5917016509 | |||
| 7b4f1d249d | |||
| 5425f9268e | |||
| febd866098 | |||
| 2446593fff | |||
| 651426cf2e | |||
| cf46f6e0ae | |||
| 6f15a84ccf | |||
| c39433c361 | |||
| 257796ce87 | |||
|
|
2357602f50 | ||
| 1230f6b984 | |||
| 14b775f1b9 | |||
|
|
c7691d9807 | ||
| 9a53d55678 | |||
|
|
31008ef7ff | ||
| 621ab260c0 | |||
| 2b1840214e | |||
|
|
5cfccc2ead | ||
|
|
774b76447d | ||
| 80994bdc8e | |||
| 2e31626f87 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
2
.npmrc
@@ -1 +1 @@
|
|||||||
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
28
AGENTS.md
28
AGENTS.md
@@ -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)
|
||||||
|
|
||||||
@@ -58,14 +58,14 @@ 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 |
|
||||||
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
||||||
|
|
||||||
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
||||||
|
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@@ -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
|
||||||
|
|||||||
162
README.md
162
README.md
@@ -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:
|
||||||
@@ -215,10 +331,12 @@ The CLI also performs a background update check on every invocation (cached for
|
|||||||
### Installer Flags
|
### Installer Flags
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash tools/install.sh --check # Version check only
|
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
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/gateway",
|
"name": "@mosaicstack/gateway",
|
||||||
"version": "0.0.5",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ──────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
190
apps/gateway/src/admin/bootstrap.e2e.spec.ts
Normal file
190
apps/gateway/src/admin/bootstrap.e2e.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
IProviderAdapter,
|
IProviderAdapter,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
} from '@mosaic/types';
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anthropic provider adapter.
|
* Anthropic provider adapter.
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
IProviderAdapter,
|
IProviderAdapter,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
} from '@mosaic/types';
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAI provider adapter.
|
* OpenAI provider adapter.
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
ModelInfo,
|
ModelInfo,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
ProviderInfo,
|
ProviderInfo,
|
||||||
} from '@mosaic/types';
|
} from '@mosaicstack/types';
|
||||||
import {
|
import {
|
||||||
AnthropicAdapter,
|
AnthropicAdapter,
|
||||||
OllamaAdapter,
|
OllamaAdapter,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
63
apps/gateway/src/federation/__tests__/peer-key.spec.ts
Normal file
63
apps/gateway/src/federation/__tests__/peer-key.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/gateway/src/federation/ca.dto.ts
Normal file
57
apps/gateway/src/federation/ca.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
577
apps/gateway/src/federation/ca.service.spec.ts
Normal file
577
apps/gateway/src/federation/ca.service.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
635
apps/gateway/src/federation/ca.service.ts
Normal file
635
apps/gateway/src/federation/ca.service.ts
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
/**
|
||||||
|
* 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 } 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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/gateway/src/federation/federation.module.ts
Normal file
8
apps/gateway/src/federation/federation.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CaService } from './ca.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [CaService],
|
||||||
|
exports: [CaService],
|
||||||
|
})
|
||||||
|
export class FederationModule {}
|
||||||
9
apps/gateway/src/federation/peer-key.util.ts
Normal file
9
apps/gateway/src/federation/peer-key.util.ts
Normal 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);
|
||||||
|
}
|
||||||
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal file
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
apps/gateway/src/federation/scope-schema.ts
Normal file
147
apps/gateway/src/federation/scope-schema.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaic/db';
|
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
"rootDir": "../..",
|
"rootDir": "../..",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@mosaic/auth": ["../../packages/auth/src/index.ts"],
|
"@mosaicstack/auth": ["../../packages/auth/src/index.ts"],
|
||||||
"@mosaic/brain": ["../../packages/brain/src/index.ts"],
|
"@mosaicstack/brain": ["../../packages/brain/src/index.ts"],
|
||||||
"@mosaic/coord": ["../../packages/coord/src/index.ts"],
|
"@mosaicstack/coord": ["../../packages/coord/src/index.ts"],
|
||||||
"@mosaic/db": ["../../packages/db/src/index.ts"],
|
"@mosaicstack/db": ["../../packages/db/src/index.ts"],
|
||||||
"@mosaic/log": ["../../packages/log/src/index.ts"],
|
"@mosaicstack/log": ["../../packages/log/src/index.ts"],
|
||||||
"@mosaic/memory": ["../../packages/memory/src/index.ts"],
|
"@mosaicstack/memory": ["../../packages/memory/src/index.ts"],
|
||||||
"@mosaic/types": ["../../packages/types/src/index.ts"],
|
"@mosaicstack/types": ["../../packages/types/src/index.ts"],
|
||||||
"@mosaic/discord-plugin": ["../../plugins/discord/src/index.ts"],
|
"@mosaicstack/discord-plugin": ["../../plugins/discord/src/index.ts"],
|
||||||
"@mosaic/telegram-plugin": ["../../plugins/telegram/src/index.ts"]
|
"@mosaicstack/telegram-plugin": ["../../plugins/telegram/src/index.ts"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import swc from 'unplugin-swc';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -5,4 +6,22 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
},
|
},
|
||||||
|
plugins: [
|
||||||
|
swc.vite({
|
||||||
|
jsc: {
|
||||||
|
parser: {
|
||||||
|
syntax: 'typescript',
|
||||||
|
decorators: true,
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
decoratorMetadata: true,
|
||||||
|
legacyDecorator: true,
|
||||||
|
},
|
||||||
|
target: 'es2022',
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
type: 'nodenext',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { NextConfig } from 'next';
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
transpilePackages: ['@mosaic/design-tokens'],
|
transpilePackages: ['@mosaicstack/design-tokens'],
|
||||||
|
|
||||||
// Enable gzip/brotli compression for all responses.
|
// Enable gzip/brotli compression for all responses.
|
||||||
compress: true,
|
compress: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/web",
|
"name": "@mosaicstack/web",
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaic/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
* - Next.js web app running on http://localhost:3000
|
* - Next.js web app running on http://localhost:3000
|
||||||
* - NestJS gateway running on http://localhost:14242
|
* - NestJS gateway running on http://localhost:14242
|
||||||
*
|
*
|
||||||
* Run with: pnpm --filter @mosaic/web test:e2e
|
* Run with: pnpm --filter @mosaicstack/web test:e2e
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
|
|||||||
70
deploy/portainer/README.md
Normal file
70
deploy/portainer/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# deploy/portainer/
|
||||||
|
|
||||||
|
Portainer stack templates for Mosaic Stack deployments.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| -------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `federated-test.stack.yml` | Docker Swarm stack for federation end-to-end test instances (`mos-test-1.woltje.com`, `mos-test-2.woltje.com`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## federated-test.stack.yml
|
||||||
|
|
||||||
|
A self-contained Swarm stack that boots a federated-tier Mosaic gateway with co-located Postgres 17 (pgvector) and Valkey 8. This is a **test template** — production deployments will use a separate template with stricter resource limits and Docker secrets.
|
||||||
|
|
||||||
|
### Deploy via Portainer UI
|
||||||
|
|
||||||
|
1. Log into Portainer.
|
||||||
|
2. Navigate to **Stacks → Add stack**.
|
||||||
|
3. Set a stack name matching `STACK_NAME` below (e.g. `mos-test-1`).
|
||||||
|
4. Choose **Web editor** and paste the contents of `federated-test.stack.yml`.
|
||||||
|
5. Scroll to **Environment variables** and add each variable listed below.
|
||||||
|
6. Click **Deploy the stack**.
|
||||||
|
|
||||||
|
### Required environment variables
|
||||||
|
|
||||||
|
| Variable | Example | Notes |
|
||||||
|
| -------------------- | --------------------------------------- | -------------------------------------------------------- |
|
||||||
|
| `STACK_NAME` | `mos-test-1` | Unique per stack — used in Traefik router/service names. |
|
||||||
|
| `HOST_FQDN` | `mos-test-1.woltje.com` | Fully-qualified hostname served by this stack. |
|
||||||
|
| `POSTGRES_PASSWORD` | _(generate randomly)_ | Database password. Do **not** reuse between stacks. |
|
||||||
|
| `BETTER_AUTH_SECRET` | _(generate: `openssl rand -base64 32`)_ | BetterAuth session signing key. |
|
||||||
|
| `BETTER_AUTH_URL` | `https://mos-test-1.woltje.com` | Public base URL of the gateway. |
|
||||||
|
|
||||||
|
Optional variables (uncomment in the YAML or set in Portainer):
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
| ----------------------------- | ---------------------------------------------------------- |
|
||||||
|
| `ANTHROPIC_API_KEY` | Enable Claude models. |
|
||||||
|
| `OPENAI_API_KEY` | Enable OpenAI models. |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Forward traces to a collector (e.g. `http://jaeger:4318`). |
|
||||||
|
|
||||||
|
### Required external resources
|
||||||
|
|
||||||
|
Before deploying, ensure the following exist on the Swarm:
|
||||||
|
|
||||||
|
1. **`traefik-public` overlay network** — shared network Traefik uses to route traffic to stacks.
|
||||||
|
```bash
|
||||||
|
docker network create --driver overlay --attachable traefik-public
|
||||||
|
```
|
||||||
|
2. **`letsencrypt` cert resolver** — configured in the Traefik Swarm stack. The stack template references `tls.certresolver=letsencrypt`; the name must match your Traefik config.
|
||||||
|
3. **DNS A record** — `${HOST_FQDN}` must resolve to the Swarm ingress IP (or a Cloudflare-proxied address pointing there).
|
||||||
|
|
||||||
|
### Deployed instances
|
||||||
|
|
||||||
|
| Stack name | HOST_FQDN | Purpose |
|
||||||
|
| ------------ | ----------------------- | ---------------------------------- |
|
||||||
|
| `mos-test-1` | `mos-test-1.woltje.com` | DEPLOY-03 — first federation peer |
|
||||||
|
| `mos-test-2` | `mos-test-2.woltje.com` | DEPLOY-04 — second federation peer |
|
||||||
|
|
||||||
|
### Image
|
||||||
|
|
||||||
|
The gateway image is pinned by digest to `fed-v0.1.0-m1` (verified in DEPLOY-01). Update the digest in the YAML when promoting a new build — never use `:latest` or a mutable tag in Swarm.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- This template boots a **vanilla M1-baseline gateway** in federated tier. Federation grants (Step-CA, mTLS) are M2+ scope and not included here.
|
||||||
|
- Each stack gets its own Postgres volume (`postgres-data`) and Valkey volume (`valkey-data`) scoped to the stack name by Swarm.
|
||||||
|
- `depends_on` is honoured by Compose but ignored by Swarm — healthchecks on Postgres and Valkey ensure the gateway retries until they are ready.
|
||||||
160
deploy/portainer/federated-test.stack.yml
Normal file
160
deploy/portainer/federated-test.stack.yml
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# deploy/portainer/federated-test.stack.yml
|
||||||
|
#
|
||||||
|
# Portainer / Docker Swarm stack template — federated-tier test instance
|
||||||
|
#
|
||||||
|
# PURPOSE
|
||||||
|
# Deploys a single federated-tier Mosaic gateway with co-located Postgres
|
||||||
|
# (pgvector) and Valkey for end-to-end federation testing. Intended for
|
||||||
|
# mos-test-1.woltje.com and mos-test-2.woltje.com (DEPLOY-03/04).
|
||||||
|
#
|
||||||
|
# REQUIRED ENV VARS (set per-stack in Portainer → Stacks → Environment variables)
|
||||||
|
# STACK_NAME Unique name for Traefik router/service labels.
|
||||||
|
# Examples: mos-test-1, mos-test-2
|
||||||
|
# HOST_FQDN Fully-qualified domain name served by this stack.
|
||||||
|
# Examples: mos-test-1.woltje.com, mos-test-2.woltje.com
|
||||||
|
# POSTGRES_PASSWORD Database password — set per stack; do NOT commit a default.
|
||||||
|
# BETTER_AUTH_SECRET Random 32-char string for BetterAuth session signing.
|
||||||
|
# Generate: openssl rand -base64 32
|
||||||
|
# BETTER_AUTH_URL Public gateway base URL, e.g. https://mos-test-1.woltje.com
|
||||||
|
#
|
||||||
|
# OPTIONAL ENV VARS (uncomment and set in Portainer to enable features)
|
||||||
|
# ANTHROPIC_API_KEY sk-ant-...
|
||||||
|
# OPENAI_API_KEY sk-...
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT http://<collector>:4318
|
||||||
|
# OTEL_SERVICE_NAME (default: mosaic-gateway)
|
||||||
|
#
|
||||||
|
# REQUIRED EXTERNAL RESOURCES
|
||||||
|
# traefik-public Docker overlay network — must exist before deploying.
|
||||||
|
# Create: docker network create --driver overlay --attachable traefik-public
|
||||||
|
# letsencrypt Traefik cert resolver configured on the Swarm manager.
|
||||||
|
# DNS A record ${HOST_FQDN} → Swarm ingress IP (or Cloudflare proxy).
|
||||||
|
#
|
||||||
|
# IMAGE
|
||||||
|
# Pinned to sha-9f1a081 (main HEAD post-#488 Dockerfile fix). The previous
|
||||||
|
# pin (fed-v0.1.0-m1, sha256:9b72e2...) had a broken pnpm copy and could
|
||||||
|
# not resolve @mosaicstack/storage at runtime. The new digest was smoke-
|
||||||
|
# tested locally — gateway boots, imports resolve, tier-detector runs.
|
||||||
|
# Update digest here when promoting a new build.
|
||||||
|
#
|
||||||
|
# HEALTHCHECK NOTE (2026-04-21)
|
||||||
|
# Switched from busybox wget to node http.get on 127.0.0.1 (not localhost) to
|
||||||
|
# avoid IPv6 resolution issues on Alpine. Retries increased to 5 and
|
||||||
|
# start_period to 60s to cover the NestJS/GC cold-start window (~40-50s).
|
||||||
|
# restart_policy set to `any` so SIGTERM/clean-exit also triggers restart.
|
||||||
|
#
|
||||||
|
# NOTE: This is a TEST template — production deployments use a separate
|
||||||
|
# parameterised template with stricter resource limits and secrets.
|
||||||
|
|
||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
gateway:
|
||||||
|
image: git.mosaicstack.dev/mosaicstack/stack/gateway@sha256:1069117740e00ccfeba357cae38c43f3729fe5ae702740ce474f6512414d7c02
|
||||||
|
# Tag for human reference: sha-9f1a081 (post-#488 Dockerfile fix; smoke-tested locally)
|
||||||
|
environment:
|
||||||
|
# ── Tier ───────────────────────────────────────────────────────────────
|
||||||
|
MOSAIC_TIER: federated
|
||||||
|
|
||||||
|
# ── Database ───────────────────────────────────────────────────────────
|
||||||
|
DATABASE_URL: postgres://gateway:${POSTGRES_PASSWORD}@postgres:5432/mosaic
|
||||||
|
|
||||||
|
# ── Queue ──────────────────────────────────────────────────────────────
|
||||||
|
VALKEY_URL: redis://valkey:6379
|
||||||
|
|
||||||
|
# ── Gateway ────────────────────────────────────────────────────────────
|
||||||
|
GATEWAY_PORT: '3000'
|
||||||
|
GATEWAY_CORS_ORIGIN: https://${HOST_FQDN}
|
||||||
|
|
||||||
|
# ── Auth ───────────────────────────────────────────────────────────────
|
||||||
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
|
BETTER_AUTH_URL: https://${HOST_FQDN}
|
||||||
|
|
||||||
|
# ── Observability ──────────────────────────────────────────────────────
|
||||||
|
OTEL_SERVICE_NAME: ${STACK_NAME:-mosaic-gateway}
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT: http://<collector>:4318
|
||||||
|
|
||||||
|
# ── AI Providers (uncomment to enable) ─────────────────────────────────
|
||||||
|
# ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||||
|
# OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
networks:
|
||||||
|
- federated-test
|
||||||
|
- traefik-public
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: any
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.docker.network=traefik-public'
|
||||||
|
- 'traefik.http.routers.${STACK_NAME}.rule=Host(`${HOST_FQDN}`)'
|
||||||
|
- 'traefik.http.routers.${STACK_NAME}.entrypoints=websecure'
|
||||||
|
- 'traefik.http.routers.${STACK_NAME}.tls=true'
|
||||||
|
- 'traefik.http.routers.${STACK_NAME}.tls.certresolver=letsencrypt'
|
||||||
|
- 'traefik.http.services.${STACK_NAME}.loadbalancer.server.port=3000'
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- 'CMD'
|
||||||
|
- 'node'
|
||||||
|
- '-e'
|
||||||
|
- "require('http').get('http://127.0.0.1:3000/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- valkey
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: gateway
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: mosaic
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- federated-test
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U gateway']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8-alpine
|
||||||
|
volumes:
|
||||||
|
- valkey-data:/data
|
||||||
|
networks:
|
||||||
|
- federated-test
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'valkey-cli', 'ping']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
valkey-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
federated-test:
|
||||||
|
driver: overlay
|
||||||
|
traefik-public:
|
||||||
|
external: true
|
||||||
120
docker-compose.federated.yml
Normal file
120
docker-compose.federated.yml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# docker-compose.federated.yml — Federated tier overlay
|
||||||
|
#
|
||||||
|
# USAGE:
|
||||||
|
# docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
#
|
||||||
|
# This file is a standalone overlay for the Mosaic federated tier.
|
||||||
|
# It is NOT an extension of docker-compose.yml — it defines its own services
|
||||||
|
# and named volumes so it can run independently of the base dev stack.
|
||||||
|
#
|
||||||
|
# IMPORTANT — HOST PORT CONFLICTS:
|
||||||
|
# The federated services bind the same host ports as the base dev stack
|
||||||
|
# (5433 for Postgres, 6380 for Valkey). You must stop the base dev stack
|
||||||
|
# before starting the federated stack on the same machine:
|
||||||
|
# docker compose down
|
||||||
|
# docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
#
|
||||||
|
# pgvector extension:
|
||||||
|
# The vector extension is created automatically at first boot via
|
||||||
|
# ./infra/pg-init/01-extensions.sql (CREATE EXTENSION IF NOT EXISTS vector).
|
||||||
|
#
|
||||||
|
# Tier configuration:
|
||||||
|
# Used by `mosaic` instances configured with `tier: federated`.
|
||||||
|
# DEFAULT_FEDERATED_CONFIG points at:
|
||||||
|
# postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres-federated:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: mosaic
|
||||||
|
POSTGRES_PASSWORD: mosaic
|
||||||
|
POSTGRES_DB: mosaic
|
||||||
|
volumes:
|
||||||
|
- pg_federated_data:/var/lib/postgresql/data
|
||||||
|
- ./infra/pg-init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U mosaic']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
valkey-federated:
|
||||||
|
image: valkey/valkey:8-alpine
|
||||||
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
||||||
|
volumes:
|
||||||
|
- valkey_federated_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'valkey-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step-CA — Mosaic Federation internal certificate authority
|
||||||
|
#
|
||||||
|
# Image: pinned to 0.27.4 (latest stable as of late 2025).
|
||||||
|
# `latest` is forbidden per Mosaic image policy (immutable tag required for
|
||||||
|
# reproducible deployments and digest-first promotion in CI).
|
||||||
|
#
|
||||||
|
# Profile: `federated` — this service must not start in non-federated dev.
|
||||||
|
#
|
||||||
|
# Password:
|
||||||
|
# Dev: bind-mount ./infra/step-ca/dev-password (gitignored; copy from
|
||||||
|
# ./infra/step-ca/dev-password.example and customise locally).
|
||||||
|
# Prod: replace the bind-mount with a Docker secret:
|
||||||
|
# secrets:
|
||||||
|
# ca_password:
|
||||||
|
# external: true
|
||||||
|
# and reference it as `/run/secrets/ca_password` (same path the
|
||||||
|
# init script already uses).
|
||||||
|
#
|
||||||
|
# Provisioner: "mosaic-fed" (consumed by apps/gateway/src/federation/ca.service.ts)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step-ca:
|
||||||
|
image: smallstep/step-ca:0.27.4
|
||||||
|
profiles: [federated]
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '${STEP_CA_HOST_PORT:-9000}:9000'
|
||||||
|
volumes:
|
||||||
|
- step_ca_data:/home/step
|
||||||
|
# init script — executed as the container entrypoint
|
||||||
|
- ./infra/step-ca/init.sh:/usr/local/bin/mosaic-step-ca-init.sh:ro
|
||||||
|
# X.509 template skeleton (wired in M2-04)
|
||||||
|
- ./infra/step-ca/templates:/etc/step-ca-templates:ro
|
||||||
|
# Dev password file — GITIGNORED; copy from dev-password.example
|
||||||
|
# In production, replace this with a Docker secret (see comment above).
|
||||||
|
- ./infra/step-ca/dev-password:/run/secrets/ca_password:ro
|
||||||
|
entrypoint: ['/bin/sh', '/usr/local/bin/mosaic-step-ca-init.sh']
|
||||||
|
healthcheck:
|
||||||
|
# The healthcheck requires the root cert to exist, which is only true
|
||||||
|
# after init.sh has completed on first boot. start_period gives init
|
||||||
|
# time to finish before Docker starts counting retries.
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'step',
|
||||||
|
'ca',
|
||||||
|
'health',
|
||||||
|
'--ca-url',
|
||||||
|
'https://localhost:9000',
|
||||||
|
'--root',
|
||||||
|
'/home/step/certs/root_ca.crt',
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_federated_data:
|
||||||
|
valkey_federated_data:
|
||||||
|
step_ca_data:
|
||||||
@@ -5,18 +5,27 @@ RUN corepack enable
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Copy workspace manifests first for layer-cached install
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
|
COPY plugins/ ./plugins/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @mosaic/gateway build
|
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||||
|
RUN pnpm turbo run build --filter @mosaicstack/gateway...
|
||||||
|
# Produce a self-contained deploy artifact: flat node_modules, no pnpm symlinks
|
||||||
|
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
|
||||||
|
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||||
|
COPY --from=builder /deploy/node_modules ./node_modules
|
||||||
|
COPY --from=builder /deploy/package.json ./package.json
|
||||||
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||||
COPY --from=builder /app/apps/gateway/package.json ./package.json
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -1,70 +1,116 @@
|
|||||||
# Mission Manifest — Harness Foundation
|
# Mission Manifest — MVP
|
||||||
|
|
||||||
> Persistent document tracking full mission scope, status, and session history.
|
> Top-level rollup tracking Mosaic Stack MVP execution.
|
||||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
> Workstreams have their own manifests; this document is the source of truth for MVP scope, status, and history.
|
||||||
|
> Owner: Orchestrator (sole writer).
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
|
|
||||||
**ID:** harness-20260321
|
**ID:** mvp-20260312
|
||||||
**Statement:** Transform Mosaic Stack from a functional demo into a real multi-provider, task-routing AI harness. Persist all conversations, integrate frontier LLM providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama), build granular task-aware agent routing, harden agent sessions, replace cron with BullMQ, and design the channel protocol for future Matrix/remote integration.
|
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
||||||
**Phase:** Complete
|
**Phase:** Execution (workstream W1 in planning-complete state)
|
||||||
**Current Milestone:** All milestones done
|
**Current Workstream:** W1 — Federation v1
|
||||||
**Progress:** 7 / 7 milestones
|
**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||||
**Status:** complete
|
**Status:** active (continuous since 2026-03-13)
|
||||||
**Last Updated:** 2026-03-22 UTC
|
**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468)
|
||||||
|
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
||||||
|
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Jarvis (v0.2.0) was a single-host Python/Next.js assistant. The user runs sessions across 3–4 workstations split between home and USC. Today every session reaches back to a single jarvis-brain checkout, which is brittle (offline-hostile, no consolidation, no shared state beyond a single repo). A prior OpenBrain attempt punished offline use, introduced cache/latency/opacity pain, and tightly coupled every session to a remote service.
|
||||||
|
|
||||||
|
The MVP solution: keep each user's home gateway as the source of truth, connect gateways gateway-to-gateway over mTLS with scoped read-only data exposure, and expose the unified experience through three coherent surfaces:
|
||||||
|
|
||||||
|
- **webUI** — the primary visual control plane (Next.js + React 19, `apps/web`)
|
||||||
|
- **TUI** — the terminal-native interface for agent work (`packages/mosaic` wizard + Pi TUI)
|
||||||
|
- **CLI** — `mosaic` command for scripted/headless workflows
|
||||||
|
|
||||||
|
Federation is required NOW because it unblocks cross-host consolidation; it is necessary but not sufficient for MVP. Additional workstreams will be declared as their scope solidifies.
|
||||||
|
|
||||||
|
## Prior Execution (March 13 → April 5)
|
||||||
|
|
||||||
|
This manifest was authored on 2026-04-19 to rollup work that began 2026-03-13. Before this date, MVP work was tracked via phase-based Gitea milestones and the scratchpad — there was no rollup manifest at the `docs/MISSION-MANIFEST.md` path (the slot was occupied by sub-mission manifests for `install-ux-hardening` and then `install-ux-v2`).
|
||||||
|
|
||||||
|
Prior execution outline (full detail in [scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md)):
|
||||||
|
|
||||||
|
- **Phases 0 → 7** (Gitea milestones `ms-157` → `ms-164`, issues #1–#59): foundation, core API, agent layer, web dashboard, memory, remote control, CLI/tools, polish/beta. Substantially shipped by Session 13.
|
||||||
|
- **Phase 8** (Gitea milestone `ms-165`, issues #160–#172): platform architecture extension — teams, workspaces, `/provider` OAuth, preferences, etc. Wave-based execution plan defined at Session 14.
|
||||||
|
- **Sub-missions** during the gap: `install-ux-hardening` (complete, `mosaic-v0.0.25`), `install-ux-v2` (complete on 2026-04-19, `0.0.27` → `0.0.29`). Both archived under `docs/archive/missions/`.
|
||||||
|
|
||||||
|
Going forward, MVP execution is tracked through the **Workstreams** table below. Phase-based issue numbering is preserved on Gitea but is no longer the primary control plane.
|
||||||
|
|
||||||
|
## Cross-Cutting MVP Requirements
|
||||||
|
|
||||||
|
These apply to every workstream and every milestone. A workstream cannot ship if it breaks any of them.
|
||||||
|
|
||||||
|
| # | Requirement |
|
||||||
|
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| MVP-X1 | Three-surface parity: every user-facing capability is reachable via webUI **and** TUI **and** CLI (read paths at minimum; mutating paths where applicable to the surface). |
|
||||||
|
| MVP-X2 | Multi-tenant isolation is enforced at every boundary; no cross-user leakage under any circumstance. |
|
||||||
|
| MVP-X3 | Auth via BetterAuth (existing); SSO adapters per PRD; admin bootstrap remains a one-shot. |
|
||||||
|
| MVP-X4 | Three quality gates green before push: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`. |
|
||||||
|
| MVP-X5 | Federated tier (PG + pgvector + Valkey) is the canonical MVP deployment topology; local/standalone tiers continue to work for non-federated installs but are not the MVP target. |
|
||||||
|
| MVP-X6 | OTEL tracing on every request path; `traceparent` propagated across the federation boundary in both directions. |
|
||||||
|
| MVP-X7 | Trunk merge strategy: branch from `main`, squash-merge via PR, never push to `main` directly. |
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
- [x] AC-1: Send messages in TUI → restart TUI → resume conversation → agent has full history and context
|
The MVP is complete when ALL declared workstreams are complete AND every cross-cutting requirement is verifiable on a live two-host deployment (woltje.com ↔ uscllc.com).
|
||||||
- [x] AC-2: Route a coding task to Claude Opus 4.6, a simple question to Haiku, a summarization to GLM-5 — all via granular routing rules
|
|
||||||
- [x] AC-3: Two users exist, User A's memory searches never return User B's data
|
|
||||||
- [x] AC-4: `/model claude-sonnet-4-6` in TUI switches the active model for subsequent messages
|
|
||||||
- [x] AC-5: `/agent coding-agent` in TUI switches to a different agent with different system prompt and tools
|
|
||||||
- [x] AC-6: BullMQ jobs execute on schedule, failures retry with backoff, admin can inspect via `/api/admin/jobs`
|
|
||||||
- [x] AC-7: Channel protocol document exists with Matrix integration points defined, reviewed, and approved
|
|
||||||
- [x] AC-8: Embeddings run on Ollama local models (no external API dependency for vector operations)
|
|
||||||
- [x] AC-9: All five providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama) connect, list models, and complete chat requests
|
|
||||||
- [x] AC-10: Routing transparency — TUI displays which model was selected and the routing reason for each response
|
|
||||||
|
|
||||||
## Milestones
|
- [ ] AC-MVP-1: All declared workstreams reach `complete` status with merged PRs and green CI
|
||||||
|
- [ ] AC-MVP-2: A user session on the home gateway can transparently query work-gateway data subject to scope, with no data persisted across the boundary
|
||||||
|
- [ ] AC-MVP-3: The same user-facing capability is reachable from webUI, TUI, and CLI (per MVP-X1)
|
||||||
|
- [ ] AC-MVP-4: Two-gateway production deployment (woltje.com ↔ uscllc.com) operational ≥7 days without incident
|
||||||
|
- [ ] AC-MVP-5: All cross-cutting requirements (MVP-X1 → MVP-X7) verified with evidence
|
||||||
|
- [ ] AC-MVP-6: PRD `docs/PRD.md` "In Scope (v0.1.0 Beta)" list mapped to evidence (each item: shipped / explicitly deferred with rationale)
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
## Workstreams
|
||||||
| --- | ------ | ---------------------------------- | ------ | ------ | --------- | ---------- | ---------- |
|
|
||||||
| 1 | ms-166 | Conversation Persistence & Context | done | — | #224–#231 | 2026-03-21 | 2026-03-21 |
|
|
||||||
| 2 | ms-167 | Security & Isolation | done | — | #232–#239 | 2026-03-21 | 2026-03-21 |
|
|
||||||
| 3 | ms-168 | Provider Integration | done | — | #240–#251 | 2026-03-21 | 2026-03-22 |
|
|
||||||
| 4 | ms-169 | Agent Routing Engine | done | — | #252–#264 | 2026-03-22 | 2026-03-22 |
|
|
||||||
| 5 | ms-170 | Agent Session Hardening | done | — | #265–#272 | 2026-03-22 | 2026-03-22 |
|
|
||||||
| 6 | ms-171 | Job Queue Foundation | done | — | #273–#280 | 2026-03-22 | 2026-03-22 |
|
|
||||||
| 7 | ms-172 | Channel Protocol Design | done | — | #281–#288 | 2026-03-22 | 2026-03-22 |
|
|
||||||
|
|
||||||
## Deployment
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
|
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
|
| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||||
|
|
||||||
| Target | URL | Method |
|
### Likely Additional Workstreams (Not Yet Declared)
|
||||||
| -------------------- | --------- | -------------------------- |
|
|
||||||
| Docker Compose (dev) | localhost | docker compose up |
|
|
||||||
| Production | TBD | Docker Swarm via Portainer |
|
|
||||||
|
|
||||||
## Coordination
|
These are anticipated based on the PRD `In Scope` list but are NOT counted toward MVP completion until they have their own manifest, milestones, and tracking issues. Listed here so the orchestrator knows what's likely coming.
|
||||||
|
|
||||||
- **Primary Agent:** claude-opus-4-6
|
- Web dashboard parity with PRD scope (chat, tasks, projects, missions, agent status surfaces)
|
||||||
- **Sibling Agents:** sonnet (workers), haiku (verification)
|
- Pi TUI integration for terminal-native agent work
|
||||||
- **Shared Contracts:** docs/PRD-Harness_Foundation.md, docs/TASKS.md
|
- CLI completeness for headless / scripted workflows that mirror webUI capability
|
||||||
|
- Remote control plugins (Discord priority, then Telegram)
|
||||||
|
- Multi-user / SSO finishing (BetterAuth + Authentik/WorkOS/Keycloak adapters per PRD)
|
||||||
|
- LLM provider expansion (Anthropic, Codex, Z.ai, Ollama, LM Studio, llama.cpp) + routing matrix
|
||||||
|
- MCP server/client capability + skill import interface
|
||||||
|
- Brain (`@mosaicstack/brain`) as the structured data layer on PG + vector
|
||||||
|
|
||||||
## Token Budget
|
When any of these solidify into a real workstream, add a row to the Workstreams table, create a workstream-level manifest under `docs/{workstream}/MISSION-MANIFEST.md`, and file tracking issues.
|
||||||
|
|
||||||
| Metric | Value |
|
## Risks
|
||||||
| ------ | ------ |
|
|
||||||
| Budget | — |
|
- **Scope creep is the named risk.** Workstreams will be added; the rule is that each must have its own manifest + milestones + acceptance criteria before it consumes execution capacity.
|
||||||
| Used | ~2.5M |
|
- **Federation urgency vs. surface parity** — federation is being built first because it unblocks the user, but webUI/TUI/CLI parity (MVP-X1) cannot slip indefinitely. Track surface coverage explicitly when each workstream lands.
|
||||||
| Mode | normal |
|
- **Three-surface fan-out** — the same capability exposed three ways multiplies test surface and design effort. Default to a shared API/contract layer, then thin surface adapters; resist surface-specific business logic.
|
||||||
|
- **Federated-tier dependency** — MVP requires PG + pgvector + Valkey; users on local/standalone tier cannot federate. This is intentional but must be communicated clearly in the wizard.
|
||||||
|
|
||||||
|
## Out of Scope (MVP)
|
||||||
|
|
||||||
|
- SaaS / multi-tenant revenue model — personal/family/team tool only
|
||||||
|
- Mobile native apps — responsive web only
|
||||||
|
- Public npm registry publishing — Gitea registry only
|
||||||
|
- Voice / video agent interaction
|
||||||
|
- Full OpenClaw feature parity — inspiration only
|
||||||
|
- Calendar / GLPI / Woodpecker tooling integrations (deferred to post-MVP)
|
||||||
|
|
||||||
## Session History
|
## Session History
|
||||||
|
|
||||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
For sessions 1–14 (phase-based execution, 2026-03-13 → 2026-03-15), see [scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md). Sessions below are tracked at the rollup level.
|
||||||
| ------- | --------------- | ---------- | -------- | ------------ | ----------------- |
|
|
||||||
| 1 | claude-opus-4-6 | 2026-03-21 | ~6h | complete | M7-008 — all done |
|
|
||||||
|
|
||||||
## Scratchpad
|
| Session | Date | Runtime | Outcome |
|
||||||
|
| ------- | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| S15 | 2026-04-19 | claude | MVP rollup manifest authored. Install-ux-v2 archived (IUV-M03 retroactively closed — shipped via PR #446 + releases 0.0.27 → 0.0.29). Federation v1 planning landed via PR #468. W1 manifest reachable at `docs/federation/MISSION-MANIFEST.md`. Next: kickoff FED-M1. |
|
||||||
|
|
||||||
Path: `docs/scratchpads/harness-20260321.md`
|
## Next Step
|
||||||
|
|
||||||
|
Begin W1 / FED-M1 — federated tier infrastructure. Task breakdown lives at [docs/federation/TASKS.md](./federation/TASKS.md).
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ for any `<Image>` components added in the future.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run the DB migration (requires a live DB)
|
# Run the DB migration (requires a live DB)
|
||||||
pnpm --filter @mosaic/db exec drizzle-kit migrate
|
pnpm --filter @mosaicstack/db exec drizzle-kit migrate
|
||||||
|
|
||||||
# Or, in Docker/Swarm — migrations run automatically on gateway startup
|
# Or, in Docker/Swarm — migrations run automatically on gateway startup
|
||||||
# via runMigrations() in packages/db/src/migrate.ts
|
# via runMigrations() in packages/db/src/migrate.ts
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user