Compare commits
226 Commits
fix/popula
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 495f73bfdb | |||
| b96cc7982a | |||
| 0883fb91ec | |||
| 56787fabf1 | |||
| 940ae3cc41 | |||
| c25a551c28 | |||
| 94d6538061 | |||
| a3c1ab923c | |||
| 838701bde2 | |||
| 86e106fcc9 | |||
| 67135d3822 | |||
| adb153428b | |||
| c739256a2c | |||
| fc2970916f | |||
| 79eae2ffce | |||
| 7035cd23bf | |||
| 6b94d014a8 | |||
| 838c44086c | |||
| 3eeed04e17 | |||
| e0e7be70f5 | |||
| d7eaa19380 | |||
| 248193cd3b | |||
| a9857c5043 | |||
| e4ede69144 | |||
| a8008138c8 | |||
| 0d17a29ebe | |||
| 28cfecda94 | |||
| 6c84ccd0b1 | |||
| 84d2757817 | |||
| a738ac1410 | |||
| 538f0556d5 | |||
| a094c86eea | |||
| f852250419 | |||
| 61b1bdac2a | |||
| cabb179d5a | |||
| eb795bab18 | |||
| 937077f6be | |||
| 1020cfaf9b | |||
| 70661e3fab | |||
| ec8dd7ca86 | |||
| d887555852 | |||
| e3adc6a1bc | |||
| aa27c42129 | |||
| 16ae809442 | |||
| 6980e40e51 | |||
| e6b53ea103 | |||
| 4da87640e8 | |||
| a38a491403 | |||
| 78d67c6261 | |||
| 94e5cd7a81 | |||
| 4e84f8e850 | |||
| cf8ceb3095 | |||
| bf2a6745c8 | |||
| d539d61e0e | |||
| 3f69d45334 | |||
| e2336bb0ca | |||
| 7342415a32 | |||
| 095e19443b | |||
| fabc413407 | |||
| 858d90329d | |||
| 2bf66136e4 | |||
| 4434c3c481 | |||
| dd0a0d38c6 | |||
| d46ac40890 | |||
| 8ddd48c843 | |||
| 528700ceea | |||
| 32f4215461 | |||
| 23343bb7f0 | |||
| c8b2dab0ca | |||
| 6dbe452a9f | |||
| 59c755067e | |||
| 6ffb27787e | |||
| 130837365f | |||
| 67df06f1c4 | |||
| 60a309d5a4 | |||
| 2dc0f24828 | |||
| 31e7a4d25e | |||
| ca19d57bba | |||
| bb7d549080 | |||
| 5bef2c35eb | |||
| 2849a8f9db | |||
| 7ced5588c9 | |||
| afcbbb302f | |||
| c2c0b5fe8d | |||
| c9cfe36204 | |||
| fc90c89913 | |||
| af2eede7a9 | |||
| 5118be74cb | |||
| bf24066a49 | |||
| 92316ab41e | |||
| b354bc8fae | |||
| e834bbb83c | |||
| 7498fcb20d | |||
| 42d081613f | |||
| b5c1381e45 | |||
| 6dfd78f643 | |||
| 45e2c2aad8 | |||
| 57919c38d8 | |||
| 87f561c1f8 | |||
| 8c45857859 | |||
| 605221d42f | |||
| ee584ab48c | |||
| ab4e138003 | |||
| 719c6ac3db | |||
| b8807e60df | |||
| c461380a4a | |||
| 98a771c8f8 | |||
| bd9527c033 | |||
| aa221bf92e | |||
| 799df40f4e | |||
| b79e9f32c6 | |||
| 89d69eb23b | |||
| 59b611ba8a | |||
| dfa0be42f6 | |||
| bb96a3f23e | |||
| 48b2f28e45 | |||
| 8f09c910a9 | |||
| dde95a59b3 | |||
| 821e19dcbb | |||
| 755df9079e | |||
| ac5650d9f9 | |||
| bd83f86740 | |||
|
|
0af3e218a1 | ||
|
|
b01c9b3bb0 | ||
| b67f2c9f08 | |||
|
|
37675ae3f2 | ||
|
|
a4a6769a6d | ||
|
|
21650fb194 | ||
| 89c733e0b9 | |||
| ee3f2defd9 | |||
| 7342c1290d | |||
| e64ddd2c1c | |||
| 4ece6dc643 | |||
| 194c3b603e | |||
| fc1600b738 | |||
| 0ee5b14c68 | |||
| 3eee176cc3 | |||
| 74fe60d8d6 | |||
| 0bfaa56e9e | |||
| 01dd6b9fa1 | |||
| 1038ae76e1 | |||
| 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 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -9,3 +9,16 @@ coverage
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
infra/step-ca/dev-password
|
||||
|
||||
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
||||
.mosaic-test-work/
|
||||
|
||||
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
|
||||
# loading it, then unlink. They are untracked but were not ignored, so turbo's
|
||||
# package traversal hashed them and intermittently failed CI with "Package
|
||||
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
||||
# file vanished mid-scan. Ignoring them removes the race.
|
||||
*.timestamp-*.mjs
|
||||
|
||||
4
.npmrc
4
.npmrc
@@ -1 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||
# instead of repopulating a fresh one.
|
||||
store-dir=/root/.local/share/pnpm/store
|
||||
|
||||
40
.woodpecker/ci-image.yml
Normal file
40
.woodpecker/ci-image.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
|
||||
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
|
||||
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
|
||||
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
|
||||
# for their install step.
|
||||
#
|
||||
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
|
||||
# code push must not trigger a 25-min image build. `path` applies to push/PR
|
||||
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
|
||||
# always ships a fresh base.
|
||||
when:
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'Dockerfile.ci'
|
||||
|
||||
steps:
|
||||
build-ci-base:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
# Lockfile-hash tag: an immutable identity for the exact dep set baked
|
||||
# into this image. `:latest` is the mutable pointer pipelines consume.
|
||||
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
|
||||
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS
|
||||
@@ -1,5 +1,9 @@
|
||||
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
|
||||
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
|
||||
# store. The install step resolves from the baked store (--prefer-offline)
|
||||
# instead of paying a ~731s cold fetch + native compile every run.
|
||||
variables:
|
||||
- &node_image 'node:22-alpine'
|
||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||
- &enable_pnpm 'corepack enable'
|
||||
|
||||
when:
|
||||
@@ -15,8 +19,21 @@ steps:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
- apk add --no-cache python3 make g++
|
||||
- pnpm install --frozen-lockfile
|
||||
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
|
||||
# the baked pnpm store.
|
||||
- pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
# Blocking gate: public framework package must contain no operator-specific
|
||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
||||
sanitization:
|
||||
image: *node_image
|
||||
commands:
|
||||
- apk add --no-cache bash
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
||||
# Resident line-count ceiling over framework-owned resident files
|
||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
@@ -25,6 +42,7 @@ steps:
|
||||
- pnpm typecheck
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
@@ -46,18 +64,27 @@ steps:
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
|
||||
# Avoid the namespace-level Woodpecker DB service named "postgres".
|
||||
# The Kubernetes backend exposes service containers by step name.
|
||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# Install postgresql-client for pg_isready
|
||||
- apk add --no-cache postgresql-client
|
||||
# Wait up to 30s for postgres to be ready
|
||||
# postgresql-client (pg_isready) is baked into ci-base.
|
||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||
- |
|
||||
for i in $(seq 1 30); do
|
||||
pg_isready -h postgres -p 5432 -U mosaic && break
|
||||
echo "Waiting for postgres ($i/30)..."
|
||||
ready=0
|
||||
for i in $(seq 1 60); do
|
||||
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
echo "Waiting for ci-postgres ($i/60)..."
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "ci-postgres did not become ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Run migrations (DATABASE_URL is set in environment above)
|
||||
- pnpm --filter @mosaicstack/db run db:migrate
|
||||
# Run all tests
|
||||
@@ -66,7 +93,7 @@ steps:
|
||||
- typecheck
|
||||
|
||||
services:
|
||||
postgres:
|
||||
ci-postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
POSTGRES_USER: mosaic
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
# Build, publish npm packages, and push Docker images
|
||||
# Runs only on main branch push/tag
|
||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||
|
||||
variables:
|
||||
- &node_image 'node:22-alpine'
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||
# toolchain + warm pnpm store. Kills the second cold install publish pays.
|
||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||
- &enable_pnpm 'corepack enable'
|
||||
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
||||
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
||||
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
|
||||
# (tags) always build everything. Exclude-list keeps the default SAFE: any
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main]
|
||||
- branch: [main, next]
|
||||
event: [push, manual, tag]
|
||||
|
||||
steps:
|
||||
@@ -14,7 +45,8 @@ steps:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
# Resolve from the baked pnpm store instead of a cold network fetch.
|
||||
- pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
@@ -26,6 +58,15 @@ steps:
|
||||
|
||||
publish-npm:
|
||||
image: *node_image
|
||||
# Publish only when a publishable package changed (or on a release tag); a
|
||||
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
|
||||
when:
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- 'packages/**'
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
@@ -45,21 +86,27 @@ steps:
|
||||
# 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 2>&1 | tee /tmp/publish.log
|
||||
EXIT=${PIPESTATUS[0]}
|
||||
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
|
||||
# Any hard registry/auth/network error fails the pipeline.
|
||||
if grep -qE "E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND" /tmp/publish.log; then
|
||||
# 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
|
||||
# Tolerate only the specific "version already published" case.
|
||||
if grep -qE "EPUBLISHCONFLICT|cannot publish over|previously published" /tmp/publish.log; then
|
||||
# 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
|
||||
@@ -68,6 +115,84 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
publish-next-npm:
|
||||
image: *node_image
|
||||
# Durable @next integration-line publish. Runs only on next; never writes
|
||||
# the latest dist-tag and never commits the computed prerelease versions.
|
||||
when:
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- |
|
||||
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
||||
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
||||
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
}
|
||||
const [, major, minor, patch] = stableMatch;
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
||||
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -85,6 +210,7 @@ steps:
|
||||
|
||||
build-gateway:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -97,19 +223,29 @@ steps:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:latest"
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next gateway publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:$CI_COMMIT_TAG"
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
build-web:
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -122,12 +258,38 @@ steps:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:latest"
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:$CI_COMMIT_TAG"
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
|
||||
@@ -25,7 +25,7 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo
|
||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
||||
| `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/telegram` | Telegram channel plugin | Telegraf |
|
||||
|
||||
@@ -59,9 +59,9 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
||||
|
||||
| Value | When to use | Budget |
|
||||
| -------- | ----------------------------------------------------------- | -------------------------- |
|
||||
| --------- | ----------------------------------------------------------- | -------------------------- |
|
||||
| `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 |
|
||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||
|
||||
@@ -10,7 +10,7 @@ Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
||||
- **Auth**: BetterAuth (`packages/auth`)
|
||||
- **Agent**: Pi SDK (`packages/agent`, `packages/cli`)
|
||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
||||
- **Queue**: Valkey 8 (`packages/queue`)
|
||||
- **Build**: pnpm workspaces + Turborepo
|
||||
- **CI**: Woodpecker CI
|
||||
|
||||
45
Dockerfile.ci
Normal file
45
Dockerfile.ci
Normal file
@@ -0,0 +1,45 @@
|
||||
# Pre-baked CI base image for Woodpecker pipelines.
|
||||
#
|
||||
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
|
||||
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
|
||||
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
|
||||
# already fetched at build time. `pnpm fetch` only populates the store from the
|
||||
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
|
||||
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
|
||||
# which is exactly why the musl toolchain stays baked into this image. A
|
||||
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
|
||||
# tarballs from local hard-links (no network) and compiles natives against the
|
||||
# already-present toolchain, in tens of seconds instead of ~731s.
|
||||
#
|
||||
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
|
||||
# (see .woodpecker/ci-image.yml).
|
||||
#
|
||||
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
|
||||
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
|
||||
# carries zero cache variables. node:26 stays held until it reaches LTS
|
||||
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
|
||||
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
|
||||
FROM node:24-alpine
|
||||
|
||||
# Native toolchain required to compile node-gyp deps on musl, plus the
|
||||
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
|
||||
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
|
||||
# `apk add bash`.
|
||||
RUN apk add --no-cache python3 make g++ postgresql-client bash
|
||||
|
||||
# Pin pnpm to the repo's packageManager version via corepack.
|
||||
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Pin the store location so the pipeline can point `store-dir` at the same path.
|
||||
ENV PNPM_HOME=/root/.local/share/pnpm
|
||||
RUN pnpm config set store-dir /root/.local/share/pnpm/store
|
||||
|
||||
# Warm the store. `pnpm fetch` populates the content-addressable store with the
|
||||
# dependency tarballs directly from the lockfile (no package.json / workspace
|
||||
# needed), so a baked store stays valid until the lockfile changes. Note:
|
||||
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
|
||||
# in the pipeline, against the toolchain baked above.
|
||||
COPY pnpm-lock.yaml ./
|
||||
RUN pnpm fetch --frozen-lockfile
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Mosaic Stack
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
162
README.md
162
README.md
@@ -7,26 +7,49 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
||||
## Quick Install
|
||||
|
||||
```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:
|
||||
|
||||
| Component | What | Where |
|
||||
| -------------------- | ----------------------------------------------------- | -------------------- |
|
||||
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/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:
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||
|
||||
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
||||
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
mosaic init # Interactive wizard
|
||||
mosaic wizard # Full guided setup (gateway install → verify)
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js ≥ 20
|
||||
- npm (for global @mosaicstack/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)
|
||||
|
||||
## Usage
|
||||
@@ -45,14 +68,40 @@ mosaic yolo pi # Pi in yolo mode
|
||||
|
||||
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
|
||||
|
||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
||||
|
||||
### TUI & Gateway
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
@@ -65,6 +114,80 @@ mosaic coord init # Initialize a new orchestration mission
|
||||
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
|
||||
|
||||
### Prerequisites
|
||||
@@ -76,8 +199,8 @@ mosaic prdy init # Create a PRD via guided session
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git
|
||||
cd mosaic-stack
|
||||
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||
docker compose up -d
|
||||
@@ -126,13 +249,12 @@ npm packages are published to the Gitea package registry on main merges.
|
||||
## Architecture
|
||||
|
||||
```
|
||||
mosaic-stack/
|
||||
stack/
|
||||
├── apps/
|
||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
||||
├── packages/
|
||||
│ ├── cli/ Mosaic CLI — TUI, gateway client, wizard
|
||||
│ ├── mosaic/ Framework — wizard, runtime detection, update checker
|
||||
│ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
|
||||
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
|
||||
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
|
||||
│ ├── auth/ BetterAuth configuration
|
||||
@@ -153,7 +275,7 @@ mosaic-stack/
|
||||
│ ├── macp/ OpenClaw MACP runtime plugin
|
||||
│ └── mosaic-framework/ OpenClaw framework injection plugin
|
||||
├── 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
|
||||
├── docker-compose.yml Dev infrastructure
|
||||
└── .woodpecker/ CI pipeline configs
|
||||
@@ -200,7 +322,13 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
||||
Run the installer again — it handles upgrades automatically:
|
||||
|
||||
```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:
|
||||
@@ -218,7 +346,11 @@ The CLI also performs a background update check on every invocation (cached for
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
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 --next # Prerelease lane: source build from next
|
||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
35
apps/appservice/package.json
Normal file
35
apps/appservice/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic-as",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"directory": "apps/appservice"
|
||||
},
|
||||
"main": "dist/main.js",
|
||||
"bin": {
|
||||
"mosaic-as": "dist/main.js",
|
||||
"mosaic-as-registration": "dist/registration-main.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"dev": "tsx watch src/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/appservice": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
388
apps/appservice/src/__tests__/server.test.ts
Normal file
388
apps/appservice/src/__tests__/server.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppserviceDaemon } from '../server.js';
|
||||
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
||||
|
||||
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
|
||||
|
||||
const cfg: DaemonConfig = {
|
||||
homeserverUrl: 'https://hs.example',
|
||||
domain: 'hs.example',
|
||||
asToken: 'as-secret',
|
||||
hsToken: 'hs-secret',
|
||||
bridgeTokens: ['bridge-secret'],
|
||||
};
|
||||
|
||||
const jsonResponse = (status: number, body: unknown): Response =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
const request = (overrides: Partial<DaemonRequest>): DaemonRequest => ({
|
||||
method: 'GET',
|
||||
path: '/',
|
||||
searchParams: new URLSearchParams(),
|
||||
body: undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDaemon = () => {
|
||||
const fetchMock = vi.fn(async (_input: URL | string) => jsonResponse(200, { event_id: '$sent' }));
|
||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
||||
return { daemon, fetchMock };
|
||||
};
|
||||
|
||||
describe('AppserviceDaemon routing', () => {
|
||||
it('serves health unauthenticated', async () => {
|
||||
const { daemon } = makeDaemon();
|
||||
expect((await daemon.handle(request({ path: '/health' }))).status).toBe(200);
|
||||
});
|
||||
|
||||
it('404s unknown paths', async () => {
|
||||
const { daemon } = makeDaemon();
|
||||
expect((await daemon.handle(request({ path: '/nope' }))).status).toBe(404);
|
||||
});
|
||||
|
||||
it('transactions require the hs_token', async () => {
|
||||
const { daemon } = makeDaemon();
|
||||
const bad = await daemon.handle(
|
||||
request({
|
||||
method: 'PUT',
|
||||
path: '/_matrix/app/v1/transactions/t1',
|
||||
authorizationHeader: 'Bearer wrong',
|
||||
body: { events: [] },
|
||||
}),
|
||||
);
|
||||
expect(bad.status).toBe(403);
|
||||
const ok = await daemon.handle(
|
||||
request({
|
||||
method: 'PUT',
|
||||
path: '/_matrix/app/v1/transactions/t1',
|
||||
authorizationHeader: 'Bearer hs-secret',
|
||||
body: { events: [{ type: 'm.room.message', event_id: '$e' }] },
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
});
|
||||
|
||||
it('bridge requires a bridge token (hs/as tokens do not work)', async () => {
|
||||
const { daemon } = makeDaemon();
|
||||
for (const token of [undefined, 'Bearer hs-secret', 'Bearer as-secret', 'Bearer nope']) {
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/messages',
|
||||
authorizationHeader: token,
|
||||
body: {},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('bridge message sends as the agent and returns the event id', async () => {
|
||||
const { daemon, fetchMock } = makeDaemon();
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/messages',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi', thread_root: '$req' },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_id).toBe('$sent');
|
||||
const sendCall = fetchMock.mock.calls
|
||||
.map((c) => new URL(String(c[0])))
|
||||
.find((u) => u.pathname.includes('/send/m.room.message/'));
|
||||
expect(sendCall).toBeDefined();
|
||||
expect(sendCall!.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example');
|
||||
});
|
||||
|
||||
it('bridge rejects invalid payloads with 400', async () => {
|
||||
const { daemon } = makeDaemon();
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/messages',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: { room_id: 'bad', agent: 'pi0', body: 'x' },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('bridge typing endpoint works', async () => {
|
||||
const { daemon, fetchMock } = makeDaemon();
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/typing',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', typing: true },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const typingCall = fetchMock.mock.calls
|
||||
.map((c) => new URL(String(c[0])))
|
||||
.find((u) => u.pathname.includes('/typing/'));
|
||||
expect(typingCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('authenticated unknown bridge sub-paths return 405, never fall through', async () => {
|
||||
const { daemon } = makeDaemon();
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'GET',
|
||||
path: '/bridge/v1/unknown',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(405);
|
||||
});
|
||||
|
||||
it('provisions a room as the AS sender with space linking', async () => {
|
||||
const calls: Array<{ url: URL; body: unknown }> = [];
|
||||
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
||||
if (url.pathname.endsWith('/createRoom'))
|
||||
return jsonResponse(200, { room_id: '!new:hs.example' });
|
||||
return jsonResponse(200, {});
|
||||
});
|
||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/provision/rooms',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: {
|
||||
name: 'proj-x',
|
||||
alias: 'mosaic-proj-x',
|
||||
invite: ['@jason.woltje:hs.example'],
|
||||
space_id: '!space:hs.example',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.room_id).toBe('!new:hs.example');
|
||||
expect(res.body.space_linked).toBe(true);
|
||||
const create = calls.find((c) => c.url.pathname.endsWith('/createRoom'));
|
||||
expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example');
|
||||
const body = create!.body as Record<string, unknown>;
|
||||
expect(body.room_alias_name).toBe('mosaic-proj-x');
|
||||
expect((body.power_level_content_override as Record<string, unknown>).users).toEqual({
|
||||
'@mosaic-as:hs.example': 100,
|
||||
});
|
||||
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true);
|
||||
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('space-link failure still returns the room id (no orphan)', async () => {
|
||||
const fetchMock = vi.fn(async (input: URL | string) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname.endsWith('/createRoom'))
|
||||
return jsonResponse(200, { room_id: '!new:hs.example' });
|
||||
if (url.pathname.includes('/state/m.space.child/'))
|
||||
return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' });
|
||||
return jsonResponse(200, {});
|
||||
});
|
||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/provision/rooms',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: { name: 'proj-x', space_id: '!space:hs.example' },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.room_id).toBe('!new:hs.example');
|
||||
expect(res.body.space_linked).toBe(false);
|
||||
expect(String(res.body.space_error)).toContain('403');
|
||||
});
|
||||
|
||||
it('invite list cap enforced', async () => {
|
||||
const { daemon } = makeDaemon();
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/provision/rooms',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('provision rejects bad payloads and requires auth', async () => {
|
||||
const { daemon } = makeDaemon();
|
||||
const noAuth = await daemon.handle(
|
||||
request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }),
|
||||
);
|
||||
expect(noAuth.status).toBe(403);
|
||||
const bad = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/provision/rooms',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: { name: '', alias: 'BAD ALIAS' },
|
||||
}),
|
||||
);
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
// A daemon whose fetch mock backs account_data with a mutable in-test object,
|
||||
// so register/verify/revoke round-trip through the (faked) homeserver.
|
||||
const makeAgentDaemon = () => {
|
||||
const accountData: { value: Record<string, unknown> | null } = { value: null };
|
||||
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
const path = url.pathname;
|
||||
if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
|
||||
if (init?.method === 'PUT') {
|
||||
accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
return jsonResponse(200, {});
|
||||
}
|
||||
if (accountData.value === null) {
|
||||
return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
|
||||
}
|
||||
return jsonResponse(200, accountData.value);
|
||||
}
|
||||
if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
|
||||
if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
|
||||
return jsonResponse(200, {});
|
||||
});
|
||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
||||
return { daemon, fetchMock };
|
||||
};
|
||||
|
||||
const registerAgent = async (
|
||||
daemon: AppserviceDaemon,
|
||||
body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
|
||||
) =>
|
||||
daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/agents',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body,
|
||||
}),
|
||||
);
|
||||
|
||||
it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
|
||||
const { daemon, fetchMock } = makeAgentDaemon();
|
||||
const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
||||
expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
|
||||
const registerCall = fetchMock.mock.calls
|
||||
.map((c) => new URL(String(c[0])))
|
||||
.find((u) => u.pathname.endsWith('/register'));
|
||||
expect(registerCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('register requires a HOST token (agent token and no token are 403)', async () => {
|
||||
const { daemon } = makeAgentDaemon();
|
||||
const minted = await registerAgent(daemon);
|
||||
const agentToken = String(minted.body.bridge_token);
|
||||
|
||||
const asAgent = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/agents',
|
||||
authorizationHeader: `Bearer ${agentToken}`,
|
||||
body: { alias: 'pi1', host: 'web2' },
|
||||
}),
|
||||
);
|
||||
expect(asAgent.status).toBe(403);
|
||||
|
||||
const noAuth = await daemon.handle(
|
||||
request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
|
||||
);
|
||||
expect(noAuth.status).toBe(403);
|
||||
});
|
||||
|
||||
it('agent-scoped token may send as itself but not as another agent', async () => {
|
||||
const { daemon } = makeAgentDaemon();
|
||||
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
||||
const agentToken = String(minted.body.bridge_token);
|
||||
|
||||
const self = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/messages',
|
||||
authorizationHeader: `Bearer ${agentToken}`,
|
||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
||||
}),
|
||||
);
|
||||
expect(self.status).toBe(200);
|
||||
|
||||
const other = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/messages',
|
||||
authorizationHeader: `Bearer ${agentToken}`,
|
||||
body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
|
||||
}),
|
||||
);
|
||||
expect(other.status).toBe(403);
|
||||
expect(other.body.error).toBe('token not scoped to this agent');
|
||||
});
|
||||
|
||||
it('revoked agent token is rejected on messages', async () => {
|
||||
const { daemon } = makeAgentDaemon();
|
||||
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
||||
const agentToken = String(minted.body.bridge_token);
|
||||
|
||||
const revoke = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/agents/revoke',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: { agent_user_id: '@agent-pi0-web1:hs.example' },
|
||||
}),
|
||||
);
|
||||
expect(revoke.status).toBe(200);
|
||||
expect(revoke.body.revoked).toBe(1);
|
||||
|
||||
const afterRevoke = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/messages',
|
||||
authorizationHeader: `Bearer ${agentToken}`,
|
||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
||||
}),
|
||||
);
|
||||
expect(afterRevoke.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
|
||||
const { daemon } = makeAgentDaemon();
|
||||
await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });
|
||||
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'GET',
|
||||
path: '/bridge/v1/agents',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const agents = res.body.agents as Array<Record<string, unknown>>;
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
||||
expect(agents[0]?.display_name).toBe('Pi Zero');
|
||||
});
|
||||
|
||||
it('empty bridge token list denies everything', async () => {
|
||||
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
||||
const res = await daemon.handle(
|
||||
request({
|
||||
method: 'POST',
|
||||
path: '/bridge/v1/typing',
|
||||
authorizationHeader: 'Bearer bridge-secret',
|
||||
body: {},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
23
apps/appservice/src/config.ts
Normal file
23
apps/appservice/src/config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { DaemonConfig } from './server.js';
|
||||
|
||||
const required = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`missing required env var ${name}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export function configFromEnv(): DaemonConfig & { port: number } {
|
||||
return {
|
||||
homeserverUrl: required('MOSAIC_AS_HOMESERVER_URL'),
|
||||
domain: required('MOSAIC_AS_DOMAIN'),
|
||||
asToken: required('MOSAIC_AS_TOKEN'),
|
||||
hsToken: required('MOSAIC_HS_TOKEN'),
|
||||
userPrefix: process.env.MOSAIC_AS_USER_PREFIX ?? 'agent-',
|
||||
senderLocalpart: process.env.MOSAIC_AS_SENDER_LOCALPART ?? 'mosaic-as',
|
||||
bridgeTokens: (process.env.MOSAIC_AS_BRIDGE_TOKENS ?? '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
port: Number(process.env.MOSAIC_AS_PORT ?? 8008),
|
||||
};
|
||||
}
|
||||
67
apps/appservice/src/main.ts
Normal file
67
apps/appservice/src/main.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import http from 'node:http';
|
||||
|
||||
import { configFromEnv } from './config.js';
|
||||
import { AppserviceDaemon } from './server.js';
|
||||
|
||||
const cfg = configFromEnv();
|
||||
const daemon = new AppserviceDaemon(cfg);
|
||||
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
let rejected = false;
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (received > MAX_BODY_BYTES) {
|
||||
rejected = true;
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'request body too large' }));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (rejected) return;
|
||||
void (async () => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
let body: unknown;
|
||||
try {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
body = raw ? JSON.parse(raw) : undefined;
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ errcode: 'M_NOT_JSON', error: 'invalid json' }));
|
||||
return;
|
||||
}
|
||||
const result = await daemon.handle({
|
||||
method: req.method ?? 'GET',
|
||||
path: url.pathname,
|
||||
searchParams: url.searchParams,
|
||||
authorizationHeader: req.headers.authorization,
|
||||
body,
|
||||
});
|
||||
res.writeHead(result.status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result.body));
|
||||
})().catch((error: unknown) => {
|
||||
console.error('request failed:', error);
|
||||
if (res.headersSent) {
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'internal error' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(cfg.port, () => {
|
||||
console.log(
|
||||
`mosaic-as listening on :${cfg.port} (homeserver ${cfg.homeserverUrl}, domain ${cfg.domain})`,
|
||||
);
|
||||
if (cfg.bridgeTokens.length === 0) {
|
||||
console.warn('WARNING: MOSAIC_AS_BRIDGE_TOKENS is empty — bridge API will deny all requests');
|
||||
}
|
||||
});
|
||||
10
apps/appservice/src/registration-main.ts
Normal file
10
apps/appservice/src/registration-main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { buildRegistration, registrationToYaml } from '@mosaicstack/appservice';
|
||||
|
||||
import { configFromEnv } from './config.js';
|
||||
|
||||
// Prints the Synapse registration YAML (mosaic-as.yaml) for the current env.
|
||||
// Usage: MOSAIC_AS_URL=http://mosaic-as:8008 mosaic-as-registration > mosaic-as.yaml
|
||||
const cfg = configFromEnv();
|
||||
const url = process.env.MOSAIC_AS_URL;
|
||||
if (!url) throw new Error('missing required env var MOSAIC_AS_URL');
|
||||
process.stdout.write(registrationToYaml(buildRegistration(cfg, { url })));
|
||||
225
apps/appservice/src/server.ts
Normal file
225
apps/appservice/src/server.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import {
|
||||
AgentTokenStore,
|
||||
AppserviceIntent,
|
||||
TransactionHandler,
|
||||
validateBridgeMessage,
|
||||
validateBridgeTyping,
|
||||
validateProvisionRoom,
|
||||
validateRegisterAgent,
|
||||
validateRevokeAgent,
|
||||
} from '@mosaicstack/appservice';
|
||||
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
|
||||
|
||||
export interface DaemonConfig extends AppserviceConfig {
|
||||
/** Bearer tokens accepted on /bridge/v1/* (one per agent-comms host daemon). */
|
||||
bridgeTokens: string[];
|
||||
}
|
||||
|
||||
export interface DaemonRequest {
|
||||
method: string;
|
||||
/** URL path without query string. */
|
||||
path: string;
|
||||
searchParams: URLSearchParams;
|
||||
authorizationHeader?: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export interface DaemonResponse {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Compare equal-length HMAC digests so neither content nor LENGTH of the
|
||||
// stored secret is observable through timing.
|
||||
const HMAC_KEY = randomBytes(32);
|
||||
const digest = (value: string): Buffer => createHmac('sha256', HMAC_KEY).update(value).digest();
|
||||
|
||||
const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
|
||||
|
||||
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
||||
|
||||
/**
|
||||
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
|
||||
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
|
||||
* to a single virtual user and may only act as themselves.
|
||||
*/
|
||||
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
|
||||
|
||||
/**
|
||||
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
|
||||
* Application Service transactions endpoint (Synapse-facing) plus the
|
||||
* internal bridge API v1 (agent-comms daemon-facing). main.ts binds this to
|
||||
* node:http; tests drive it directly.
|
||||
*/
|
||||
export class AppserviceDaemon {
|
||||
readonly intent: AppserviceIntent;
|
||||
private readonly transactions: TransactionHandler;
|
||||
private readonly agents: AgentTokenStore;
|
||||
|
||||
constructor(
|
||||
private readonly cfg: DaemonConfig,
|
||||
fetchImpl?: typeof fetch,
|
||||
private readonly log: (line: string) => void = (line) => console.log(line),
|
||||
) {
|
||||
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
||||
this.agents = new AgentTokenStore(this.intent);
|
||||
this.transactions = new TransactionHandler({
|
||||
hsToken: cfg.hsToken,
|
||||
onEvent: (event) => this.onEvent(event),
|
||||
onError: (error, txnId) => this.log(`txn ${txnId} handler error: ${String(error)}`),
|
||||
});
|
||||
}
|
||||
|
||||
/** v1: the daemon only observes; room logic lives in the agent-comms daemons. */
|
||||
private onEvent(event: MatrixEvent): void {
|
||||
if (event.type === 'm.room.message') {
|
||||
this.log(
|
||||
`event ${event.event_id ?? '?'} in ${event.room_id ?? '?'} from ${event.sender ?? '?'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
|
||||
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
|
||||
* up in the agent token store; anything else is rejected. */
|
||||
private async bridgeAuthorized(
|
||||
authorizationHeader: string | undefined,
|
||||
): Promise<BridgePrincipal> {
|
||||
if (!authorizationHeader?.startsWith('Bearer ')) return null;
|
||||
const presented = authorizationHeader.slice('Bearer '.length);
|
||||
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
|
||||
return { kind: 'host' };
|
||||
}
|
||||
const agentUserId = await this.agents.verifyToken(presented);
|
||||
if (agentUserId) return { kind: 'agent', agentUserId };
|
||||
return null;
|
||||
}
|
||||
|
||||
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||
if (req.method === 'GET' && req.path === '/health') {
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
const txnMatch = req.method === 'PUT' ? TXN_PATH.exec(req.path) : null;
|
||||
if (txnMatch?.[1] !== undefined) {
|
||||
return this.transactions.handle(txnMatch[1], req.body, {
|
||||
authorizationHeader: req.authorizationHeader,
|
||||
accessTokenParam: req.searchParams.get('access_token') ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/bridge/v1/')) {
|
||||
const principal = await this.bridgeAuthorized(req.authorizationHeader);
|
||||
if (!principal) {
|
||||
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
||||
}
|
||||
try {
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
|
||||
if (principal.kind !== 'host') {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
|
||||
};
|
||||
}
|
||||
validateRegisterAgent(req.body);
|
||||
const { agentUserId, token } = await this.agents.register({
|
||||
alias: req.body.alias,
|
||||
host: req.body.host,
|
||||
displayName: req.body.display_name,
|
||||
});
|
||||
this.log(`registered agent ${agentUserId}`);
|
||||
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
|
||||
if (principal.kind !== 'host') {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
|
||||
};
|
||||
}
|
||||
validateRevokeAgent(req.body);
|
||||
const revoked = await this.agents.revoke(req.body.agent_user_id);
|
||||
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
|
||||
return { status: 200, body: { revoked } };
|
||||
}
|
||||
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
|
||||
if (principal.kind !== 'host') {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
|
||||
};
|
||||
}
|
||||
const agents = await this.agents.list();
|
||||
return { status: 200, body: { agents } };
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
||||
validateBridgeMessage(req.body);
|
||||
if (
|
||||
principal.kind === 'agent' &&
|
||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
||||
) {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
||||
};
|
||||
}
|
||||
const eventId = await this.intent.sendAsAgent({
|
||||
roomId: req.body.room_id,
|
||||
agent: req.body.agent,
|
||||
body: req.body.body,
|
||||
threadRoot: req.body.thread_root,
|
||||
msgtype: req.body.msgtype,
|
||||
extraContent: req.body.extra_content,
|
||||
});
|
||||
return { status: 200, body: { event_id: eventId ?? null } };
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
||||
validateBridgeTyping(req.body);
|
||||
if (
|
||||
principal.kind === 'agent' &&
|
||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
||||
) {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
||||
};
|
||||
}
|
||||
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
||||
return { status: 200, body: {} };
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/provision/rooms') {
|
||||
validateProvisionRoom(req.body);
|
||||
const result = await this.intent.createRoom({
|
||||
name: req.body.name,
|
||||
alias: req.body.alias,
|
||||
topic: req.body.topic,
|
||||
invite: req.body.invite,
|
||||
spaceId: req.body.space_id,
|
||||
});
|
||||
this.log(
|
||||
`provisioned room ${result.roomId} (${req.body.name}) space_linked=${result.spaceLinked}`,
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
room_id: result.roomId,
|
||||
space_linked: result.spaceLinked,
|
||||
...(result.spaceError ? { space_error: result.spaceError } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log(`bridge error ${req.method} ${req.path}: ${message}`);
|
||||
return { status: 400, body: { error: message } };
|
||||
}
|
||||
// Explicit: never fall out of the authenticated bridge block, so future
|
||||
// sub-paths cannot accidentally route around the auth guard above.
|
||||
return { status: 405, body: { error: 'unsupported bridge method/path' } };
|
||||
}
|
||||
|
||||
return { status: 404, body: { error: 'not found' } };
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.6",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"directory": "apps/gateway"
|
||||
},
|
||||
"type": "module",
|
||||
@@ -56,6 +56,7 @@
|
||||
"@opentelemetry/sdk-metrics": "^2.6.0",
|
||||
"@opentelemetry/sdk-node": "^0.213.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@peculiar/x509": "^2.0.0",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"better-auth": "^1.5.5",
|
||||
"bullmq": "^5.71.0",
|
||||
@@ -63,20 +64,30 @@
|
||||
"class-validator": "^0.15.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.0.0",
|
||||
"ioredis": "^5.10.0",
|
||||
"jose": "^6.2.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"openai": "^6.32.0",
|
||||
"postgres": "^3.4.8",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"socket.io": "^4.8.0",
|
||||
"uuid": "^11.0.0",
|
||||
"undici": "^7.24.6",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@swc/core": "^1.15.24",
|
||||
"@swc/helpers": "^0.5.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
|
||||
*
|
||||
* Covers MILESTONES.md acceptance test #6:
|
||||
* "`peer add <url>` on Server A yields an `active` peer record with a valid cert + key"
|
||||
*
|
||||
* This test simulates two gateways using a single bootstrapped NestJS app:
|
||||
* - "Server A": the admin API that generates a keypair and stores the cert
|
||||
* - "Server B": the enrollment endpoint that signs the CSR
|
||||
* Both share the same DB + Step-CA in the test environment.
|
||||
*
|
||||
* Prerequisites:
|
||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
*
|
||||
* Run:
|
||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
||||
* STEP_CA_URL=https://localhost:9000 \
|
||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
||||
* pnpm --filter @mosaicstack/gateway test \
|
||||
* src/__tests__/integration/federation-m2-e2e.integration.test.ts
|
||||
*
|
||||
* Obtaining Step-CA credentials:
|
||||
* # Extract provisioner key from running container:
|
||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
||||
* # Copy root cert from container:
|
||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
||||
*
|
||||
* Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set.
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import supertest from 'supertest';
|
||||
import {
|
||||
createDb,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
federationPeers,
|
||||
federationGrants,
|
||||
federationEnrollmentTokens,
|
||||
inArray,
|
||||
eq,
|
||||
} from '@mosaicstack/db';
|
||||
import * as schema from '@mosaicstack/db';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { AdminGuard } from '../../admin/admin.guard.js';
|
||||
import { FederationModule } from '../../federation/federation.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const stepCaRun =
|
||||
run &&
|
||||
process.env['STEP_CA_AVAILABLE'] === '1' &&
|
||||
!!process.env['STEP_CA_URL'] &&
|
||||
!!process.env['STEP_CA_PROVISIONER_KEY_JSON'] &&
|
||||
!!process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||
|
||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
const RUN_ID = crypto.randomUUID();
|
||||
|
||||
describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let app: NestFastifyApplication;
|
||||
let agent: ReturnType<typeof supertest>;
|
||||
let grantsService: GrantsService;
|
||||
let enrollmentService: EnrollmentService;
|
||||
|
||||
const createdTokenGrantIds: string[] = [];
|
||||
const createdGrantIds: string[] = [];
|
||||
const createdPeerIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key';
|
||||
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [FederationModule],
|
||||
providers: [{ provide: DB, useValue: db }],
|
||||
})
|
||||
.overrideGuard(AdminGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
agent = supertest(app.getHttpServer());
|
||||
|
||||
grantsService = moduleRef.get(GrantsService);
|
||||
enrollmentService = moduleRef.get(EnrollmentService);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (db && createdTokenGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationEnrollmentTokens)
|
||||
.where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (db && createdGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (db && createdPeerIds.length > 0) {
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, createdPeerIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (db && createdUserIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(inArray(schema.users.id, createdUserIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
}
|
||||
if (app)
|
||||
await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
if (handle)
|
||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #6 — peer add: keypair → enrollment → cert storage → active peer record
|
||||
// -------------------------------------------------------------------------
|
||||
it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => {
|
||||
// Create a subject user to satisfy FK on federation_grants.subject_user_id
|
||||
const userId = crypto.randomUUID();
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: userId,
|
||||
name: `e2e-user-${RUN_ID}`,
|
||||
email: `e2e-${RUN_ID}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
createdUserIds.push(userId);
|
||||
|
||||
// ── Step A: "Server B" setup ─────────────────────────────────────────
|
||||
// Server B admin creates a grant and generates an enrollment token to
|
||||
// share out-of-band with Server A's operator.
|
||||
|
||||
// Insert a placeholder peer on "Server B" to satisfy the grant FK
|
||||
const serverBPeerId = crypto.randomUUID();
|
||||
await db
|
||||
.insert(federationPeers)
|
||||
.values({
|
||||
id: serverBPeerId,
|
||||
commonName: `server-b-peer-${RUN_ID}`,
|
||||
displayName: 'Server B Placeholder',
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `serial-b-${serverBPeerId}`,
|
||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
state: 'pending',
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
createdPeerIds.push(serverBPeerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
||||
peerId: serverBPeerId,
|
||||
});
|
||||
createdGrantIds.push(grant.id);
|
||||
createdTokenGrantIds.push(grant.id);
|
||||
|
||||
const { token } = await enrollmentService.createToken({
|
||||
grantId: grant.id,
|
||||
peerId: serverBPeerId,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
// ── Step B: "Server A" generates keypair ─────────────────────────────
|
||||
const keypairRes = await agent
|
||||
.post('/api/admin/federation/peers/keypair')
|
||||
.send({
|
||||
commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`,
|
||||
displayName: 'E2E Test Peer',
|
||||
endpointUrl: 'https://test.invalid',
|
||||
})
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(keypairRes.status).toBe(201);
|
||||
const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string };
|
||||
expect(typeof peerId).toBe('string');
|
||||
expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----');
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
// ── Step C: Enrollment (simulates Server A sending CSR to Server B) ──
|
||||
const enrollRes = await agent
|
||||
.post(`/api/federation/enrollment/${token}`)
|
||||
.send({ csrPem })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(enrollRes.status).toBe(200);
|
||||
const { certPem, certChainPem } = enrollRes.body as {
|
||||
certPem: string;
|
||||
certChainPem: string;
|
||||
};
|
||||
expect(certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
|
||||
// ── Step D: "Server A" stores the cert ───────────────────────────────
|
||||
const storeRes = await agent
|
||||
.patch(`/api/admin/federation/peers/${peerId}/cert`)
|
||||
.send({ certPem })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(storeRes.status).toBe(200);
|
||||
|
||||
// ── Step E: Verify peer record in DB ─────────────────────────────────
|
||||
const [peer] = await db
|
||||
.select()
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, peerId))
|
||||
.limit(1);
|
||||
|
||||
expect(peer).toBeDefined();
|
||||
expect(peer?.state).toBe('active');
|
||||
expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(typeof peer?.certSerial).toBe('string');
|
||||
expect((peer?.certSerial ?? '').length).toBeGreaterThan(0);
|
||||
// clientKeyPem is a sealed ciphertext — must not be a raw PEM
|
||||
expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
||||
// certNotAfter must be in the future
|
||||
expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now());
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Federation M2 integration tests (FED-M2-09).
|
||||
*
|
||||
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
|
||||
*
|
||||
* Prerequisites:
|
||||
* docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
*
|
||||
* Run DB-only tests (no Step-CA):
|
||||
* FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
|
||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||
*
|
||||
* Run all tests including Step-CA-dependent ones:
|
||||
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
|
||||
* STEP_CA_URL=https://localhost:9000 \
|
||||
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
|
||||
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
|
||||
* pnpm --filter @mosaicstack/gateway test \
|
||||
* src/__tests__/integration/federation-m2.integration.test.ts
|
||||
*
|
||||
* Obtaining Step-CA credentials:
|
||||
* # Extract provisioner key from running container:
|
||||
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
|
||||
* # Copy root cert from container:
|
||||
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { GoneException } from '@nestjs/common';
|
||||
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
|
||||
import {
|
||||
createDb,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
federationPeers,
|
||||
federationGrants,
|
||||
federationEnrollmentTokens,
|
||||
inArray,
|
||||
eq,
|
||||
} from '@mosaicstack/db';
|
||||
import * as schema from '@mosaicstack/db';
|
||||
import { seal } from '@mosaicstack/auth';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { EnrollmentService } from '../../federation/enrollment.service.js';
|
||||
import { CaService } from '../../federation/ca.service.js';
|
||||
import { FederationScopeError } from '../../federation/scope-schema.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
|
||||
|
||||
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for test data isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Unique run prefix to identify rows created by this test run. */
|
||||
const RUN_ID = crypto.randomUUID();
|
||||
|
||||
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
|
||||
async function insertTestUser(db: Db, id: string): Promise<void> {
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id,
|
||||
name: `test-user-${id}`,
|
||||
email: `test-${id}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
|
||||
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
|
||||
await db
|
||||
.insert(federationPeers)
|
||||
.values({
|
||||
id,
|
||||
commonName: `test-peer-${RUN_ID}-${suffix}`,
|
||||
displayName: `Test Peer ${suffix}`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `test-serial-${id}`,
|
||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
state: 'pending',
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB-only test module (CaService mocked so env vars not required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDbModule(db: Db) {
|
||||
return Test.createTestingModule({
|
||||
providers: [
|
||||
{ provide: DB, useValue: db },
|
||||
GrantsService,
|
||||
{
|
||||
provide: CaService,
|
||||
useValue: {
|
||||
issueCert: async () => {
|
||||
throw new Error('CaService.issueCert should not be called in DB-only tests');
|
||||
},
|
||||
},
|
||||
},
|
||||
EnrollmentService,
|
||||
],
|
||||
}).compile();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite — DB-only (no Step-CA)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let grantsService: GrantsService;
|
||||
|
||||
/** IDs created during this run — cleaned up in afterAll. */
|
||||
const createdGrantIds: string[] = [];
|
||||
const createdPeerIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';
|
||||
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
|
||||
const moduleRef = await buildDbModule(db);
|
||||
grantsService = moduleRef.get(GrantsService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up in FK-safe order: tokens → grants → peers → users
|
||||
if (db && createdGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationEnrollmentTokens)
|
||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdPeerIds.length > 0) {
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, createdPeerIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdUserIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(inArray(schema.users.id, createdUserIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (handle)
|
||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #1 — grant create writes a pending row
|
||||
// -------------------------------------------------------------------------
|
||||
it('#1 — createGrant writes a pending row to DB', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'test1');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
|
||||
createdGrantIds.push(grant.id);
|
||||
|
||||
// Verify the row exists in DB with correct shape
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(federationGrants)
|
||||
.where(eq(federationGrants.id, grant.id))
|
||||
.limit(1);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.status).toBe('pending');
|
||||
expect(row?.peerId).toBe(peerId);
|
||||
expect(row?.subjectUserId).toBe(userId);
|
||||
const storedScope = row?.scope as Record<string, unknown>;
|
||||
expect(storedScope['resources']).toEqual(['tasks']);
|
||||
expect(storedScope['max_rows_per_query']).toBe(100);
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #7 — scope with unknown resource type rejected
|
||||
// -------------------------------------------------------------------------
|
||||
it('#7 — createGrant rejects scope with unknown resource type', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const invalidScope = {
|
||||
resources: ['totally_unknown_resource'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'test7');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
await expect(
|
||||
grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: invalidScope,
|
||||
peerId,
|
||||
}),
|
||||
).rejects.toThrow(FederationScopeError);
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #8 — listGrants returns accurate status for grants in various states
|
||||
// -------------------------------------------------------------------------
|
||||
it('#8 — listGrants returns accurate status for grants in various states', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['notes'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 50,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'test8');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
// Create two pending grants via GrantsService
|
||||
const grantA = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
const grantB = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
|
||||
peerId,
|
||||
});
|
||||
createdGrantIds.push(grantA.id, grantB.id);
|
||||
|
||||
// Insert a third grant directly in 'revoked' state to test status variety
|
||||
const [grantC] = await db
|
||||
.insert(federationGrants)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
subjectUserId: userId,
|
||||
peerId,
|
||||
scope: validScope,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
createdGrantIds.push(grantC!.id);
|
||||
|
||||
// List all grants for this peer
|
||||
const allForPeer = await grantsService.listGrants({ peerId });
|
||||
|
||||
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
|
||||
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
|
||||
expect(ourGrants).toHaveLength(3);
|
||||
|
||||
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
|
||||
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
|
||||
expect(pendingGrants).toHaveLength(2);
|
||||
expect(revokedGrants).toHaveLength(1);
|
||||
|
||||
// Status-filtered query
|
||||
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
|
||||
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
|
||||
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
|
||||
|
||||
// Verify peer list from DB also shows the peer rows with correct state
|
||||
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
|
||||
expect(peers).toHaveLength(1);
|
||||
expect(peers[0]?.state).toBe('pending');
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #5 — client_key_pem encrypted at rest
|
||||
// -------------------------------------------------------------------------
|
||||
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
|
||||
const peerId = crypto.randomUUID();
|
||||
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
|
||||
const sealed = seal(rawPem);
|
||||
|
||||
await db.insert(federationPeers).values({
|
||||
id: peerId,
|
||||
commonName: `test-peer-${RUN_ID}-sealed`,
|
||||
displayName: 'Sealed Key Test Peer',
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `test-serial-sealed-${peerId}`,
|
||||
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
state: 'pending',
|
||||
clientKeyPem: sealed,
|
||||
});
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, peerId))
|
||||
.limit(1);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
|
||||
expect(row?.clientKeyPem).toBeDefined();
|
||||
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
|
||||
// The sealed value should be non-trivial (at least 20 chars)
|
||||
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite — Step-CA gated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let grantsService: GrantsService;
|
||||
let enrollmentService: EnrollmentService;
|
||||
|
||||
const createdGrantIds: string[] = [];
|
||||
const createdPeerIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
|
||||
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
|
||||
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
|
||||
}).compile();
|
||||
|
||||
grantsService = moduleRef.get(GrantsService);
|
||||
enrollmentService = moduleRef.get(EnrollmentService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (db && createdGrantIds.length > 0) {
|
||||
await db
|
||||
.delete(federationEnrollmentTokens)
|
||||
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, createdGrantIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdPeerIds.length > 0) {
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, createdPeerIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (db && createdUserIds.length > 0) {
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(inArray(schema.users.id, createdUserIds))
|
||||
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
}
|
||||
if (handle)
|
||||
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
|
||||
});
|
||||
|
||||
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
|
||||
async function generateCsrPem(cn: string): Promise<string> {
|
||||
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
|
||||
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||
name: `CN=${cn}`,
|
||||
keys: keyPair,
|
||||
signingAlgorithm: alg,
|
||||
});
|
||||
return csr.toString('pem');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #2 — enrollment signs CSR and returns cert
|
||||
// -------------------------------------------------------------------------
|
||||
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'ca-test2');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
createdGrantIds.push(grant.id);
|
||||
|
||||
const { token } = await enrollmentService.createToken({
|
||||
grantId: grant.id,
|
||||
peerId,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
|
||||
const result = await enrollmentService.redeem(token, csrPem);
|
||||
|
||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
|
||||
// Verify the issued cert parses cleanly
|
||||
const cert = new PeculiarX509(result.certPem);
|
||||
expect(cert.serialNumber).toBeTruthy();
|
||||
}, 30_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// #3 — token single-use; second attempt returns GoneException
|
||||
// -------------------------------------------------------------------------
|
||||
it('#3 — second redeem of the same token throws GoneException', async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const peerId = crypto.randomUUID();
|
||||
const validScope = {
|
||||
resources: ['notes'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 50,
|
||||
};
|
||||
|
||||
await insertTestUser(db, userId);
|
||||
await insertTestPeer(db, peerId, 'ca-test3');
|
||||
createdUserIds.push(userId);
|
||||
createdPeerIds.push(peerId);
|
||||
|
||||
const grant = await grantsService.createGrant({
|
||||
subjectUserId: userId,
|
||||
scope: validScope,
|
||||
peerId,
|
||||
});
|
||||
createdGrantIds.push(grant.id);
|
||||
|
||||
const { token } = await enrollmentService.createToken({
|
||||
grantId: grant.id,
|
||||
peerId,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
|
||||
|
||||
// First redeem must succeed
|
||||
const result = await enrollmentService.redeem(token, csrPem);
|
||||
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
|
||||
|
||||
// Second redeem with the same token must be rejected
|
||||
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Federation M3 single-gateway integration tests (FED-M3-10).
|
||||
*
|
||||
* Covers MILESTONES.md M3 acceptance:
|
||||
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
|
||||
* - #7: max_rows_per_query caps list results.
|
||||
*
|
||||
* Strategy:
|
||||
* - Real PostgreSQL via @mosaicstack/db.
|
||||
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
|
||||
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
|
||||
*
|
||||
* Run:
|
||||
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
|
||||
* src/__tests__/integration/federation-m3-list.integration.test.ts
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
and,
|
||||
createDb,
|
||||
eq,
|
||||
federationGrants,
|
||||
federationPeers,
|
||||
inArray,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
teams,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
users,
|
||||
} from '@mosaicstack/db';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
|
||||
import { FederationScopeService } from '../../federation/server/scope.service.js';
|
||||
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
|
||||
import { ListController } from '../../federation/server/verbs/list.controller.js';
|
||||
import {
|
||||
makeMosaicIssuedCert,
|
||||
makeSelfSignedCert,
|
||||
} from '../../federation/__tests__/helpers/test-cert.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
|
||||
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
||||
|
||||
interface TestIds {
|
||||
readonly subjectUserId: string;
|
||||
readonly otherUserId: string;
|
||||
readonly peerId: string;
|
||||
readonly revokedPeerId: string;
|
||||
readonly activeGrantId: string;
|
||||
readonly revokedGrantId: string;
|
||||
readonly subjectProjectId: string;
|
||||
readonly subjectMissionId: string;
|
||||
readonly otherProjectId: string;
|
||||
readonly teamId: string;
|
||||
readonly unauthorizedTeamId: string;
|
||||
readonly teamProjectId: string;
|
||||
readonly taskIds: readonly string[];
|
||||
readonly excludedTaskIds: readonly string[];
|
||||
readonly subjectNoteId: string;
|
||||
readonly otherUserNoteId: string;
|
||||
}
|
||||
|
||||
function pemToDer(pem: string): Buffer {
|
||||
return Buffer.from(
|
||||
pem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\s+/g, ''),
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
|
||||
function makeFederationRequest(certPem: string): FastifyRequest {
|
||||
return {
|
||||
raw: {
|
||||
socket: {
|
||||
getPeerCertificate: () => ({
|
||||
raw: pemToDer(certPem),
|
||||
serialNumber: CERT_SERIAL_HEX,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function makeGuardContext(request: FastifyRequest): {
|
||||
readonly context: ExecutionContext;
|
||||
readonly sent: { statusCode?: number; payload?: unknown };
|
||||
} {
|
||||
const sent: { statusCode?: number; payload?: unknown } = {};
|
||||
const reply = {
|
||||
status: (statusCode: number) => {
|
||||
sent.statusCode = statusCode;
|
||||
return {
|
||||
header: () => ({
|
||||
send: (payload: unknown) => {
|
||||
sent.payload = payload;
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
} as unknown as FastifyReply;
|
||||
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => reply,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
return { context, sent };
|
||||
}
|
||||
|
||||
async function insertUser(db: Db, id: string, label: string): Promise<void> {
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
name: `${RUN_ID}-${label}`,
|
||||
email: `${RUN_ID}-${label}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedFixtures(db: Db): Promise<TestIds> {
|
||||
const subjectUserId = `${RUN_ID}-subject`;
|
||||
const otherUserId = `${RUN_ID}-other`;
|
||||
const peerId = crypto.randomUUID();
|
||||
const revokedPeerId = crypto.randomUUID();
|
||||
const activeGrantId = crypto.randomUUID();
|
||||
const revokedGrantId = crypto.randomUUID();
|
||||
const subjectProjectId = crypto.randomUUID();
|
||||
const subjectMissionId = crypto.randomUUID();
|
||||
const otherProjectId = crypto.randomUUID();
|
||||
const teamId = crypto.randomUUID();
|
||||
const unauthorizedTeamId = crypto.randomUUID();
|
||||
const teamProjectId = crypto.randomUUID();
|
||||
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const subjectNoteId = crypto.randomUUID();
|
||||
const otherUserNoteId = crypto.randomUUID();
|
||||
|
||||
await insertUser(db, subjectUserId, 'subject');
|
||||
await insertUser(db, otherUserId, 'other');
|
||||
|
||||
await db.insert(teams).values([
|
||||
{
|
||||
id: teamId,
|
||||
name: `${RUN_ID} allowed team`,
|
||||
slug: `${RUN_ID}-allowed-team`,
|
||||
ownerId: subjectUserId,
|
||||
managerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: unauthorizedTeamId,
|
||||
name: `${RUN_ID} unauthorized team`,
|
||||
slug: `${RUN_ID}-unauthorized-team`,
|
||||
ownerId: otherUserId,
|
||||
managerId: otherUserId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(teamMembers).values([
|
||||
{ teamId, userId: subjectUserId, role: 'member' },
|
||||
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
|
||||
]);
|
||||
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: subjectProjectId,
|
||||
name: `${RUN_ID} subject personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: otherProjectId,
|
||||
name: `${RUN_ID} other personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: otherUserId,
|
||||
},
|
||||
{
|
||||
id: teamProjectId,
|
||||
name: `${RUN_ID} unauthorized team project`,
|
||||
ownerType: 'team',
|
||||
teamId: unauthorizedTeamId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missions).values({
|
||||
id: subjectMissionId,
|
||||
name: `${RUN_ID} subject mission`,
|
||||
projectId: subjectProjectId,
|
||||
userId: subjectUserId,
|
||||
});
|
||||
|
||||
await db.insert(tasks).values([
|
||||
{
|
||||
id: taskIds[0],
|
||||
title: `${RUN_ID} visible task 1`,
|
||||
missionId: subjectMissionId,
|
||||
createdAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[1],
|
||||
title: `${RUN_ID} visible task 2`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[2],
|
||||
title: `${RUN_ID} visible task 3`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[0],
|
||||
title: `${RUN_ID} other user task`,
|
||||
projectId: otherProjectId,
|
||||
createdAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[1],
|
||||
title: `${RUN_ID} unauthorized team task`,
|
||||
projectId: teamProjectId,
|
||||
createdAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missionTasks).values([
|
||||
{
|
||||
id: subjectNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: subjectUserId,
|
||||
notes: `${RUN_ID} subject visible note`,
|
||||
createdAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: otherUserNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: otherUserId,
|
||||
notes: `${RUN_ID} other user note on subject mission`,
|
||||
createdAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationPeers).values([
|
||||
{
|
||||
id: peerId,
|
||||
commonName: `${RUN_ID}-active-peer`,
|
||||
displayName: `${RUN_ID} Active Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: CERT_SERIAL_HEX,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
{
|
||||
id: revokedPeerId,
|
||||
commonName: `${RUN_ID}-revoked-peer`,
|
||||
displayName: `${RUN_ID} Revoked Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationGrants).values([
|
||||
{
|
||||
id: activeGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'active',
|
||||
scope: {
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: [],
|
||||
filters: {
|
||||
tasks: { include_personal: true, include_teams: [] },
|
||||
notes: { include_personal: true, include_teams: [] },
|
||||
},
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: revokedGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
revokedReason: `${RUN_ID} revoked grant fixture`,
|
||||
scope: {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
subjectUserId,
|
||||
otherUserId,
|
||||
peerId,
|
||||
revokedPeerId,
|
||||
activeGrantId,
|
||||
revokedGrantId,
|
||||
subjectProjectId,
|
||||
subjectMissionId,
|
||||
otherProjectId,
|
||||
teamId,
|
||||
unauthorizedTeamId,
|
||||
teamProjectId,
|
||||
taskIds,
|
||||
excludedTaskIds,
|
||||
subjectNoteId,
|
||||
otherUserNoteId,
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
|
||||
if (!ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(missionTasks)
|
||||
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(tasks)
|
||||
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(missions)
|
||||
.where(eq(missions.id, ids.subjectMissionId))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(projects)
|
||||
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teamMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(teamMembers.userId, ids.subjectUserId),
|
||||
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teams)
|
||||
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(users)
|
||||
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let moduleRef: TestingModule;
|
||||
let guard: FederationAuthGuard;
|
||||
let listController: ListController;
|
||||
let ids: TestIds | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
ids = await seedFixtures(db);
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
controllers: [ListController],
|
||||
providers: [
|
||||
{ provide: DB, useValue: db },
|
||||
GrantsService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = moduleRef.get(FederationAuthGuard);
|
||||
listController = moduleRef.get(ListController);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
});
|
||||
|
||||
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
|
||||
const malformedOidCert = await makeSelfSignedCert();
|
||||
const request = makeFederationRequest(malformedOidCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(401);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: expect.stringContaining('missing required OID'),
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const revokedCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.revokedGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(revokedCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(403);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: 'Federation access denied',
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('tasks', request, { limit: 100 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(response.items).toHaveLength(2);
|
||||
expect(response._truncated).toBe(true);
|
||||
expect(response.nextCursor).toEqual(expect.any(String));
|
||||
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
|
||||
expect(returnedIds).not.toContain(ids!.taskIds[2]);
|
||||
for (const excludedId of ids!.excludedTaskIds) {
|
||||
expect(returnedIds).not.toContain(excludedId);
|
||||
}
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes another user mission task notes on the same authorized mission', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('notes', request, { limit: 10 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(returnedIds).toEqual([ids!.subjectNoteId]);
|
||||
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed for unsupported list resources', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is not supported',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
||||
import { sql, type Db } from '@mosaicstack/db';
|
||||
import { createQueue } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||
|
||||
@@ -14,6 +16,9 @@ export class AdminHealthController {
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -55,6 +60,14 @@ export class AdminHealthController {
|
||||
}
|
||||
|
||||
private async checkCache(): Promise<ServiceStatusDto> {
|
||||
// On Local tier there is no Redis. The cache is intentionally absent, which
|
||||
// is a healthy state for this tier — report 'ok' rather than opening a new
|
||||
// ioredis connection on every admin health check (which would spam
|
||||
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
||||
// signals "no cache backend to measure" for this tier.
|
||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||
return { status: 'ok', latencyMs: 0 };
|
||||
}
|
||||
const start = Date.now();
|
||||
const handle = createQueue();
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,8 @@ import type { Auth } from '@mosaicstack/auth';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { AUTH } from '../auth/auth.tokens.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')
|
||||
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,62 +1,10 @@
|
||||
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 '@mosaicstack/db';
|
||||
import { providerCredentials, eq, and } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.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()
|
||||
export class ProviderCredentialsService {
|
||||
private readonly logger = new Logger(ProviderCredentialsService.name);
|
||||
@@ -74,7 +22,7 @@ export class ProviderCredentialsService {
|
||||
value: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const encryptedValue = encrypt(value);
|
||||
const encryptedValue = seal(value);
|
||||
|
||||
await this.db
|
||||
.insert(providerCredentials)
|
||||
@@ -122,7 +70,7 @@ export class ProviderCredentialsService {
|
||||
}
|
||||
|
||||
try {
|
||||
return decrypt(row.encryptedValue);
|
||||
return unseal(row.encryptedValue);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { GCModule } from './gc/gc.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
@Module({
|
||||
@@ -52,6 +53,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
QueueModule,
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
FederationModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -21,7 +21,10 @@ export class CommandExecutorService {
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
||||
@Optional()
|
||||
@Inject(COMMANDS_REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReloadService))
|
||||
@@ -403,14 +406,16 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
const pollToken = crypto.randomUUID();
|
||||
const key = `mosaic:auth:poll:${pollToken}`;
|
||||
const pollKey = `mosaic:auth:poll:${pollToken}`;
|
||||
if (this.redis) {
|
||||
// Store pending state in Valkey (TTL 5 minutes)
|
||||
await this.redis.set(
|
||||
key,
|
||||
pollKey,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
}
|
||||
// In production this would construct an OAuth URL
|
||||
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
@@ -14,13 +16,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection.
|
||||
// CommandExecutorService falls back to no-cache for /provider login on local.
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: COMMANDS_REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
@@ -29,9 +35,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(COMMANDS_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createDb, createPgliteDb, type Db, type DbHandle } from '@mosaicstack/db';
|
||||
import {
|
||||
Global,
|
||||
Inject,
|
||||
Logger,
|
||||
Module,
|
||||
type OnApplicationShutdown,
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
createDb,
|
||||
createPgliteDb,
|
||||
runPgliteMigrations,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { createStorageAdapter, type StorageAdapter } from '@mosaicstack/storage';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
@@ -39,12 +52,37 @@ export const STORAGE_ADAPTER = 'STORAGE_ADAPTER';
|
||||
],
|
||||
exports: [DB, STORAGE_ADAPTER],
|
||||
})
|
||||
export class DatabaseModule implements OnApplicationShutdown {
|
||||
export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB_HANDLE) private readonly handle: DbHandle,
|
||||
@Inject(STORAGE_ADAPTER) private readonly storageAdapter: StorageAdapter,
|
||||
@Inject(MOSAIC_CONFIG) private readonly config: MosaicConfig,
|
||||
) {}
|
||||
|
||||
// Migrations must complete before any module that injects DB starts serving
|
||||
// requests. NestJS awaits onModuleInit before app.listen(), and modules that
|
||||
// inject DB are initialized after this one — so all DB-dependent code sees a
|
||||
// populated schema before the first HTTP request lands.
|
||||
//
|
||||
// Local (PGlite) tier: we run gateway-DB migrations explicitly here. The
|
||||
// storage adapter writes to a separate PGlite directory and only manages its
|
||||
// own KV tables, so we still call its migrate() afterwards.
|
||||
//
|
||||
// Postgres tier: PostgresAdapter.migrate() already calls runMigrations() on
|
||||
// the same DATABASE_URL, so a single call covers both the gateway DB and
|
||||
// the storage tables. We deliberately do NOT call runMigrations() here to
|
||||
// avoid opening a second short-lived connection and doubling startup cost.
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (this.config.tier === 'local') {
|
||||
this.logger.log('Applying PGlite schema migrations...');
|
||||
await runPgliteMigrations(this.handle);
|
||||
}
|
||||
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
||||
await this.storageAdapter.migrate();
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await Promise.all([this.handle.close(), this.storageAdapter.close()]);
|
||||
}
|
||||
|
||||
401
apps/gateway/src/federation/__tests__/enrollment.service.spec.ts
Normal file
401
apps/gateway/src/federation/__tests__/enrollment.service.spec.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Unit tests for EnrollmentService — federation enrollment token flow (FED-M2-07).
|
||||
*
|
||||
* Coverage:
|
||||
* createToken:
|
||||
* - inserts token row with correct grantId, peerId, and future expiresAt
|
||||
* - returns { token, expiresAt } with a 64-char hex token
|
||||
* - clamps ttlSeconds to 900
|
||||
*
|
||||
* redeem — error paths:
|
||||
* - NotFoundException when token row not found
|
||||
* - GoneException when token already used (usedAt set)
|
||||
* - GoneException when token expired (expiresAt < now)
|
||||
* - GoneException when grant status is not pending
|
||||
*
|
||||
* redeem — success path:
|
||||
* - atomically claims token BEFORE cert issuance (claim → issueCert → tx)
|
||||
* - calls CaService.issueCert with correct args
|
||||
* - activates grant + updates peer + writes audit log inside a transaction
|
||||
* - returns { certPem, certChainPem }
|
||||
*
|
||||
* redeem — replay protection:
|
||||
* - GoneException when claim UPDATE returns empty array (concurrent request won)
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
|
||||
import { GoneException, NotFoundException } from '@nestjs/common';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { EnrollmentService } from '../enrollment.service.js';
|
||||
import { makeSelfSignedCert } from './helpers/test-cert.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
|
||||
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
|
||||
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
|
||||
const TOKEN = 'a'.repeat(64); // 64-char hex
|
||||
|
||||
// Real self-signed EC P-256 cert — populated once in beforeAll.
|
||||
// Required because EnrollmentService.extractCertNotAfter calls new X509Certificate(certPem)
|
||||
// with strict parsing (PR #501 HIGH-2: no silent fallback).
|
||||
let REAL_CERT_PEM: string;
|
||||
|
||||
const MOCK_CHAIN_PEM = () => REAL_CERT_PEM + REAL_CERT_PEM;
|
||||
const MOCK_SERIAL = 'ABCD1234';
|
||||
|
||||
beforeAll(async () => {
|
||||
REAL_CERT_PEM = await makeSelfSignedCert();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTokenRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
token: TOKEN,
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
expiresAt: new Date(Date.now() + 60_000), // 1 min from now
|
||||
usedAt: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGrant(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
|
||||
status: 'pending',
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock DB builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeDb({
|
||||
tokenRows = [makeTokenRow()],
|
||||
// claimedRows is returned by the .returning() on the token-claim UPDATE.
|
||||
// Empty array = concurrent request won the race (GoneException).
|
||||
claimedRows = [{ token: TOKEN }],
|
||||
}: {
|
||||
tokenRows?: unknown[];
|
||||
claimedRows?: unknown[];
|
||||
} = {}) {
|
||||
// insert().values() — for createToken (outer db, not tx)
|
||||
const insertValues = vi.fn().mockResolvedValue(undefined);
|
||||
const insertMock = vi.fn().mockReturnValue({ values: insertValues });
|
||||
|
||||
// select().from().where().limit() — for fetching the token row
|
||||
const limitSelect = vi.fn().mockResolvedValue(tokenRows);
|
||||
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||
|
||||
// update().set().where().returning() — for the atomic token claim (outer db)
|
||||
const returningMock = vi.fn().mockResolvedValue(claimedRows);
|
||||
const whereClaimUpdate = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
const setClaimMock = vi.fn().mockReturnValue({ where: whereClaimUpdate });
|
||||
const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock });
|
||||
|
||||
// transaction(cb) — cb receives txMock; txMock has update + insert
|
||||
//
|
||||
// The tx mock must support two tx.update() call patterns (CRIT-2, PR #501):
|
||||
// 1. Grant activation: .update().set().where().returning() → resolves to [{ id }]
|
||||
// 2. Peer update: .update().set().where() → resolves to undefined
|
||||
//
|
||||
// We achieve this by making txWhereUpdate return an object with BOTH a thenable
|
||||
// interface (so `await tx.update().set().where()` works) AND a .returning() method.
|
||||
const txGrantActivatedRow = { id: GRANT_ID };
|
||||
const txReturningMock = vi.fn().mockResolvedValue([txGrantActivatedRow]);
|
||||
const txWhereUpdate = vi.fn().mockReturnValue({
|
||||
// .returning() for grant activation (first tx.update call)
|
||||
returning: txReturningMock,
|
||||
// thenables so `await tx.update().set().where()` also works for peer update
|
||||
then: (resolve: (v: undefined) => void) => resolve(undefined),
|
||||
catch: () => undefined,
|
||||
finally: () => undefined,
|
||||
});
|
||||
const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate });
|
||||
const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock });
|
||||
const txInsertValues = vi.fn().mockResolvedValue(undefined);
|
||||
const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues });
|
||||
const txMock = { update: txUpdateMock, insert: txInsertMock };
|
||||
const transactionMock = vi
|
||||
.fn()
|
||||
.mockImplementation(async (cb: (tx: typeof txMock) => Promise<void>) => cb(txMock));
|
||||
|
||||
return {
|
||||
insert: insertMock,
|
||||
select: selectMock,
|
||||
update: claimUpdateMock,
|
||||
transaction: transactionMock,
|
||||
_mocks: {
|
||||
insertValues,
|
||||
insertMock,
|
||||
limitSelect,
|
||||
whereSelect,
|
||||
fromSelect,
|
||||
selectMock,
|
||||
returningMock,
|
||||
whereClaimUpdate,
|
||||
setClaimMock,
|
||||
claimUpdateMock,
|
||||
txInsertValues,
|
||||
txInsertMock,
|
||||
txWhereUpdate,
|
||||
txReturningMock,
|
||||
txSetMock,
|
||||
txUpdateMock,
|
||||
txMock,
|
||||
transactionMock,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock CaService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeCaService() {
|
||||
return {
|
||||
// REAL_CERT_PEM is populated by beforeAll — safe to reference via closure here
|
||||
// because makeCaService() is only called after the suite's beforeAll runs.
|
||||
issueCert: vi.fn().mockImplementation(async () => ({
|
||||
certPem: REAL_CERT_PEM,
|
||||
certChainPem: MOCK_CHAIN_PEM(),
|
||||
serialNumber: MOCK_SERIAL,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock GrantsService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeGrantsService(grantOverrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
getGrant: vi.fn().mockResolvedValue(makeGrant(grantOverrides)),
|
||||
activateGrant: vi.fn().mockResolvedValue(makeGrant({ status: 'active' })),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build service under test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildService({
|
||||
db = makeDb(),
|
||||
caService = makeCaService(),
|
||||
grantsService = makeGrantsService(),
|
||||
}: {
|
||||
db?: ReturnType<typeof makeDb>;
|
||||
caService?: ReturnType<typeof makeCaService>;
|
||||
grantsService?: ReturnType<typeof makeGrantsService>;
|
||||
} = {}) {
|
||||
return new EnrollmentService(db as unknown as Db, caService as never, grantsService as never);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: createToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EnrollmentService.createToken', () => {
|
||||
it('inserts a token row and returns { token, expiresAt }', async () => {
|
||||
const db = makeDb();
|
||||
const service = buildService({ db });
|
||||
|
||||
const result = await service.createToken({
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
ttlSeconds: 900,
|
||||
});
|
||||
|
||||
expect(result.token).toHaveLength(64); // 32 bytes hex
|
||||
expect(result.expiresAt).toBeDefined();
|
||||
expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
||||
expect(db._mocks.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ grantId: GRANT_ID, peerId: PEER_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps ttlSeconds to 900', async () => {
|
||||
const db = makeDb();
|
||||
const service = buildService({ db });
|
||||
|
||||
const before = Date.now();
|
||||
const result = await service.createToken({
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
ttlSeconds: 9999,
|
||||
});
|
||||
const after = Date.now();
|
||||
|
||||
const expiresMs = new Date(result.expiresAt).getTime();
|
||||
// Should be at most 900s from now
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: redeem — error paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EnrollmentService.redeem — error paths', () => {
|
||||
it('throws NotFoundException when token row not found', async () => {
|
||||
const db = makeDb({ tokenRows: [] });
|
||||
const service = buildService({ db });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws GoneException when usedAt is set (already redeemed)', async () => {
|
||||
const db = makeDb({ tokenRows: [makeTokenRow({ usedAt: new Date(Date.now() - 1000) })] });
|
||||
const service = buildService({ db });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
});
|
||||
|
||||
it('throws GoneException when token has expired', async () => {
|
||||
const db = makeDb({ tokenRows: [makeTokenRow({ expiresAt: new Date(Date.now() - 1000) })] });
|
||||
const service = buildService({ db });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
});
|
||||
|
||||
it('throws GoneException when grant status is not pending', async () => {
|
||||
const db = makeDb();
|
||||
const grantsService = makeGrantsService({ status: 'active' });
|
||||
const service = buildService({ db, grantsService });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
});
|
||||
|
||||
it('throws GoneException when token claim UPDATE returns empty array (concurrent replay)', async () => {
|
||||
const db = makeDb({ claimedRows: [] });
|
||||
const caService = makeCaService();
|
||||
const grantsService = makeGrantsService();
|
||||
const service = buildService({ db, caService, grantsService });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
});
|
||||
|
||||
it('does NOT call issueCert when token claim fails (no double minting)', async () => {
|
||||
const db = makeDb({ claimedRows: [] });
|
||||
const caService = makeCaService();
|
||||
const service = buildService({ db, caService });
|
||||
|
||||
await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
|
||||
expect(caService.issueCert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: redeem — success path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EnrollmentService.redeem — success path', () => {
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
let caService: ReturnType<typeof makeCaService>;
|
||||
let grantsService: ReturnType<typeof makeGrantsService>;
|
||||
let service: EnrollmentService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
caService = makeCaService();
|
||||
grantsService = makeGrantsService();
|
||||
service = buildService({ db, caService, grantsService });
|
||||
});
|
||||
|
||||
it('claims token BEFORE calling issueCert (prevents double minting)', async () => {
|
||||
const callOrder: string[] = [];
|
||||
db._mocks.returningMock.mockImplementation(async () => {
|
||||
callOrder.push('claim');
|
||||
return [{ token: TOKEN }];
|
||||
});
|
||||
caService.issueCert.mockImplementation(async () => {
|
||||
callOrder.push('issueCert');
|
||||
return { certPem: REAL_CERT_PEM, certChainPem: MOCK_CHAIN_PEM(), serialNumber: MOCK_SERIAL };
|
||||
});
|
||||
|
||||
await service.redeem(TOKEN, '---CSR---');
|
||||
|
||||
expect(callOrder).toEqual(['claim', 'issueCert']);
|
||||
});
|
||||
|
||||
it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => {
|
||||
await service.redeem(TOKEN, '---CSR---');
|
||||
|
||||
expect(caService.issueCert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: USER_ID,
|
||||
csrPem: '---CSR---',
|
||||
ttlSeconds: 300,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs activate grant + peer update + audit inside a transaction', async () => {
|
||||
await service.redeem(TOKEN, '---CSR---');
|
||||
|
||||
expect(db._mocks.transactionMock).toHaveBeenCalledOnce();
|
||||
// tx.update called twice: activate grant + update peer
|
||||
expect(db._mocks.txUpdateMock).toHaveBeenCalledTimes(2);
|
||||
// tx.insert called once: audit log
|
||||
expect(db._mocks.txInsertMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('activates grant (sets status=active) inside the transaction', async () => {
|
||||
await service.redeem(TOKEN, '---CSR---');
|
||||
|
||||
expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }));
|
||||
});
|
||||
|
||||
it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => {
|
||||
await service.redeem(TOKEN, '---CSR---');
|
||||
|
||||
expect(db._mocks.txSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
certPem: REAL_CERT_PEM,
|
||||
certSerial: MOCK_SERIAL,
|
||||
state: 'active',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('inserts an audit log row inside the transaction', async () => {
|
||||
await service.redeem(TOKEN, '---CSR---');
|
||||
|
||||
expect(db._mocks.txInsertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
peerId: PEER_ID,
|
||||
grantId: GRANT_ID,
|
||||
verb: 'enrollment',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns { certPem, certChainPem } from CaService', async () => {
|
||||
const result = await service.redeem(TOKEN, '---CSR---');
|
||||
|
||||
expect(result).toEqual({
|
||||
certPem: REAL_CERT_PEM,
|
||||
certChainPem: MOCK_CHAIN_PEM(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Unit tests for FederationController (FED-M2-08).
|
||||
*
|
||||
* Coverage:
|
||||
* - listGrants: delegates to GrantsService with query params
|
||||
* - createGrant: delegates to GrantsService, validates body
|
||||
* - generateToken: returns enrollmentUrl containing the token
|
||||
* - listPeers: returns DB rows
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { FederationController } from '../federation.controller.js';
|
||||
import type { GrantsService } from '../grants.service.js';
|
||||
import type { EnrollmentService } from '../enrollment.service.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
|
||||
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
|
||||
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
|
||||
|
||||
const MOCK_GRANT = {
|
||||
id: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: { resources: ['tasks'], operations: ['list'] },
|
||||
status: 'pending' as const,
|
||||
expiresAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
};
|
||||
|
||||
const MOCK_PEER = {
|
||||
id: PEER_ID,
|
||||
commonName: 'test-peer',
|
||||
displayName: 'Test Peer',
|
||||
certPem: '',
|
||||
certSerial: 'pending',
|
||||
certNotAfter: new Date(0),
|
||||
clientKeyPem: null,
|
||||
state: 'pending' as const,
|
||||
endpointUrl: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB mock builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeDbMock(rows: unknown[] = []) {
|
||||
const orderBy = vi.fn().mockResolvedValue(rows);
|
||||
const where = vi.fn().mockReturnValue({ orderBy });
|
||||
const from = vi.fn().mockReturnValue({ where, orderBy });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
|
||||
return {
|
||||
select,
|
||||
from,
|
||||
where,
|
||||
orderBy,
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationController', () => {
|
||||
let db: Db;
|
||||
let grantsService: GrantsService;
|
||||
let enrollmentService: EnrollmentService;
|
||||
let controller: FederationController;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDbMock([MOCK_PEER]);
|
||||
|
||||
grantsService = {
|
||||
createGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
||||
getGrant: vi.fn().mockResolvedValue(MOCK_GRANT),
|
||||
listGrants: vi.fn().mockResolvedValue([MOCK_GRANT]),
|
||||
revokeGrant: vi.fn().mockResolvedValue({ ...MOCK_GRANT, status: 'revoked' }),
|
||||
activateGrant: vi.fn(),
|
||||
expireGrant: vi.fn(),
|
||||
} as unknown as GrantsService;
|
||||
|
||||
enrollmentService = {
|
||||
createToken: vi.fn().mockResolvedValue({
|
||||
token: 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12',
|
||||
expiresAt: '2026-01-01T00:15:00.000Z',
|
||||
}),
|
||||
redeem: vi.fn(),
|
||||
} as unknown as EnrollmentService;
|
||||
|
||||
controller = new FederationController(db, grantsService, enrollmentService);
|
||||
});
|
||||
|
||||
// ─── Grant management ──────────────────────────────────────────────────
|
||||
|
||||
describe('listGrants', () => {
|
||||
it('delegates to GrantsService with provided query params', async () => {
|
||||
const query = { peerId: PEER_ID, status: 'pending' as const };
|
||||
const result = await controller.listGrants(query);
|
||||
|
||||
expect(grantsService.listGrants).toHaveBeenCalledWith(query);
|
||||
expect(result).toEqual([MOCK_GRANT]);
|
||||
});
|
||||
|
||||
it('delegates to GrantsService with empty filters', async () => {
|
||||
const result = await controller.listGrants({});
|
||||
|
||||
expect(grantsService.listGrants).toHaveBeenCalledWith({});
|
||||
expect(result).toEqual([MOCK_GRANT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGrant', () => {
|
||||
it('delegates to GrantsService and returns created grant', async () => {
|
||||
const body = {
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: { resources: ['tasks'], operations: ['list'] },
|
||||
};
|
||||
|
||||
const result = await controller.createGrant(body);
|
||||
|
||||
expect(grantsService.createGrant).toHaveBeenCalledWith(body);
|
||||
expect(result).toEqual(MOCK_GRANT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGrant', () => {
|
||||
it('delegates to GrantsService with provided ID', async () => {
|
||||
const result = await controller.getGrant(GRANT_ID);
|
||||
|
||||
expect(grantsService.getGrant).toHaveBeenCalledWith(GRANT_ID);
|
||||
expect(result).toEqual(MOCK_GRANT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeGrant', () => {
|
||||
it('delegates to GrantsService with id and reason', async () => {
|
||||
const result = await controller.revokeGrant(GRANT_ID, { reason: 'test reason' });
|
||||
|
||||
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, 'test reason');
|
||||
expect(result).toMatchObject({ status: 'revoked' });
|
||||
});
|
||||
|
||||
it('delegates without reason when omitted', async () => {
|
||||
await controller.revokeGrant(GRANT_ID, {});
|
||||
|
||||
expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('returns enrollmentUrl containing the token', async () => {
|
||||
const token = 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12';
|
||||
vi.mocked(enrollmentService.createToken).mockResolvedValueOnce({
|
||||
token,
|
||||
expiresAt: '2026-01-01T00:15:00.000Z',
|
||||
});
|
||||
|
||||
const result = await controller.generateToken(GRANT_ID, { ttlSeconds: 900 });
|
||||
|
||||
expect(result.token).toBe(token);
|
||||
expect(result.enrollmentUrl).toContain(token);
|
||||
expect(result.enrollmentUrl).toContain('/api/federation/enrollment/');
|
||||
});
|
||||
|
||||
it('creates token via EnrollmentService with correct grantId and peerId', async () => {
|
||||
await controller.generateToken(GRANT_ID, { ttlSeconds: 300 });
|
||||
|
||||
expect(enrollmentService.createToken).toHaveBeenCalledWith({
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundException when grant does not exist', async () => {
|
||||
vi.mocked(grantsService.getGrant).mockRejectedValueOnce(
|
||||
new NotFoundException(`Grant ${GRANT_ID} not found`),
|
||||
);
|
||||
|
||||
await expect(controller.generateToken(GRANT_ID, { ttlSeconds: 900 })).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Peer management ───────────────────────────────────────────────────
|
||||
|
||||
describe('listPeers', () => {
|
||||
it('returns DB rows ordered by commonName', async () => {
|
||||
const result = await controller.listPeers();
|
||||
|
||||
expect(db.select).toHaveBeenCalled();
|
||||
// The DB mock resolves with [MOCK_PEER]
|
||||
expect(result).toEqual([MOCK_PEER]);
|
||||
});
|
||||
});
|
||||
});
|
||||
351
apps/gateway/src/federation/__tests__/grants.service.spec.ts
Normal file
351
apps/gateway/src/federation/__tests__/grants.service.spec.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Unit tests for GrantsService — federation grants CRUD + status transitions (FED-M2-06).
|
||||
*
|
||||
* Coverage:
|
||||
* - createGrant: validates scope via parseFederationScope
|
||||
* - createGrant: inserts with status 'pending'
|
||||
* - getGrant: returns grant when found
|
||||
* - getGrant: throws NotFoundException when not found
|
||||
* - listGrants: no filters returns all grants
|
||||
* - listGrants: filters by peerId
|
||||
* - listGrants: filters by subjectUserId
|
||||
* - listGrants: filters by status
|
||||
* - listGrants: multiple filters combined
|
||||
* - activateGrant: pending → active works
|
||||
* - activateGrant: non-pending throws ConflictException
|
||||
* - revokeGrant: active → revoked works, sets revokedAt
|
||||
* - revokeGrant: non-active throws ConflictException
|
||||
* - expireGrant: active → expired works
|
||||
* - expireGrant: non-active throws ConflictException
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { GrantsService } from '../grants.service.js';
|
||||
import { FederationScopeError } from '../scope-schema.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal valid federation scope for testing
|
||||
// ---------------------------------------------------------------------------
|
||||
const VALID_SCOPE = {
|
||||
resources: ['tasks'] as const,
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
};
|
||||
|
||||
const PEER_ID = 'a1111111-1111-1111-1111-111111111111';
|
||||
const USER_ID = 'u2222222-2222-2222-2222-222222222222';
|
||||
const GRANT_ID = 'g3333333-3333-3333-3333-333333333333';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build a mock DB that mimics chained Drizzle query builder calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockGrant(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
status: 'pending',
|
||||
expiresAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDb(
|
||||
overrides: {
|
||||
insertReturning?: unknown[];
|
||||
selectRows?: unknown[];
|
||||
updateReturning?: unknown[];
|
||||
} = {},
|
||||
) {
|
||||
const insertReturning = overrides.insertReturning ?? [makeMockGrant()];
|
||||
const selectRows = overrides.selectRows ?? [makeMockGrant()];
|
||||
const updateReturning = overrides.updateReturning ?? [makeMockGrant({ status: 'active' })];
|
||||
|
||||
// Drizzle returns a chainable builder; we need to mock the full chain.
|
||||
const returningInsert = vi.fn().mockResolvedValue(insertReturning);
|
||||
const valuesInsert = vi.fn().mockReturnValue({ returning: returningInsert });
|
||||
const insertMock = vi.fn().mockReturnValue({ values: valuesInsert });
|
||||
|
||||
// select().from().where().limit()
|
||||
const limitSelect = vi.fn().mockResolvedValue(selectRows);
|
||||
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||
// from returns something that is both thenable (for full-table select) and has .where()
|
||||
const fromSelect = vi.fn().mockReturnValue({
|
||||
where: whereSelect,
|
||||
limit: limitSelect,
|
||||
// Make it thenable for listGrants with no filters (await db.select().from(federationGrants))
|
||||
then: (resolve: (v: unknown) => unknown) => resolve(selectRows),
|
||||
});
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||
|
||||
const returningUpdate = vi.fn().mockResolvedValue(updateReturning);
|
||||
const whereUpdate = vi.fn().mockReturnValue({ returning: returningUpdate });
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdate });
|
||||
const updateMock = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
return {
|
||||
insert: insertMock,
|
||||
select: selectMock,
|
||||
update: updateMock,
|
||||
// Expose internals for assertions
|
||||
_mocks: {
|
||||
insertReturning,
|
||||
valuesInsert,
|
||||
insertMock,
|
||||
limitSelect,
|
||||
whereSelect,
|
||||
fromSelect,
|
||||
selectMock,
|
||||
returningUpdate,
|
||||
whereUpdate,
|
||||
setMock,
|
||||
updateMock,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GrantsService', () => {
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
let service: GrantsService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
});
|
||||
|
||||
// ─── createGrant ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('createGrant', () => {
|
||||
it('calls parseFederationScope — rejects an invalid scope', async () => {
|
||||
const invalidScope = { resources: [], max_rows_per_query: 0 };
|
||||
await expect(
|
||||
service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: invalidScope }),
|
||||
).rejects.toBeInstanceOf(FederationScopeError);
|
||||
});
|
||||
|
||||
it('inserts a grant with status pending and returns it', async () => {
|
||||
const result = await service.createGrant({
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
});
|
||||
|
||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'pending', peerId: PEER_ID, subjectUserId: USER_ID }),
|
||||
);
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('passes expiresAt as a Date when provided', async () => {
|
||||
await service.createGrant({
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
expiresAt: '2027-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expiresAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sets expiresAt to null when not provided', async () => {
|
||||
await service.createGrant({ peerId: PEER_ID, subjectUserId: USER_ID, scope: VALID_SCOPE });
|
||||
|
||||
expect(db._mocks.valuesInsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expiresAt: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getGrant ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getGrant', () => {
|
||||
it('returns the grant when found', async () => {
|
||||
const result = await service.getGrant(GRANT_ID);
|
||||
expect(result.id).toBe(GRANT_ID);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when no rows returned', async () => {
|
||||
db = makeDb({ selectRows: [] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
await expect(service.getGrant(GRANT_ID)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── listGrants ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('listGrants', () => {
|
||||
it('queries without where clause when no filters provided', async () => {
|
||||
const result = await service.listGrants({});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies peerId filter', async () => {
|
||||
await service.listGrants({ peerId: PEER_ID });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies subjectUserId filter', async () => {
|
||||
await service.listGrants({ subjectUserId: USER_ID });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies status filter', async () => {
|
||||
await service.listGrants({ status: 'active' });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies multiple filters combined', async () => {
|
||||
await service.listGrants({ peerId: PEER_ID, status: 'pending' });
|
||||
expect(db._mocks.whereSelect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── activateGrant ────────────────────────────────────────────────────────
|
||||
|
||||
describe('activateGrant', () => {
|
||||
it('transitions pending → active and returns updated grant', async () => {
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'pending' })],
|
||||
updateReturning: [makeMockGrant({ status: 'active' })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
const result = await service.activateGrant(GRANT_ID);
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'active' });
|
||||
expect(result.status).toBe('active');
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is already active', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'active' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is revoked', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is expired', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.activateGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── revokeGrant ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('revokeGrant', () => {
|
||||
it('transitions active → revoked and sets revokedAt', async () => {
|
||||
const revokedAt = new Date();
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'active' })],
|
||||
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
const result = await service.revokeGrant(GRANT_ID, 'test reason');
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'revoked',
|
||||
revokedAt: expect.any(Date),
|
||||
revokedReason: 'test reason',
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('revoked');
|
||||
});
|
||||
|
||||
it('sets revokedReason to null when not provided', async () => {
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'active' })],
|
||||
updateReturning: [makeMockGrant({ status: 'revoked', revokedAt: new Date() })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await service.revokeGrant(GRANT_ID);
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ revokedReason: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is pending', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is already revoked', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is expired', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.revokeGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── expireGrant ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('expireGrant', () => {
|
||||
it('transitions active → expired and returns updated grant', async () => {
|
||||
db = makeDb({
|
||||
selectRows: [makeMockGrant({ status: 'active' })],
|
||||
updateReturning: [makeMockGrant({ status: 'expired' })],
|
||||
});
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
const result = await service.expireGrant(GRANT_ID);
|
||||
|
||||
expect(db._mocks.setMock).toHaveBeenCalledWith({ status: 'expired' });
|
||||
expect(result.status).toBe('expired');
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is pending', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'pending' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is already expired', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'expired' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when grant is revoked', async () => {
|
||||
db = makeDb({ selectRows: [makeMockGrant({ status: 'revoked' })] });
|
||||
service = new GrantsService(db as unknown as Db);
|
||||
|
||||
await expect(service.expireGrant(GRANT_ID)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
});
|
||||
138
apps/gateway/src/federation/__tests__/helpers/test-cert.ts
Normal file
138
apps/gateway/src/federation/__tests__/helpers/test-cert.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Test helpers for generating real X.509 PEM certificates in unit tests.
|
||||
*
|
||||
* PR #501 (FED-M2-11) introduced strict `new X509Certificate(certPem)` parsing
|
||||
* in both EnrollmentService.extractCertNotAfter and CaService.issueCert — dummy
|
||||
* cert strings now throw `error:0680007B:asn1 encoding routines::header too long`.
|
||||
*
|
||||
* These helpers produce minimal but cryptographically valid self-signed EC P-256
|
||||
* certificates via @peculiar/x509 + Node.js webcrypto, suitable for test mocks.
|
||||
*
|
||||
* Two variants:
|
||||
* - makeSelfSignedCert() Plain cert — satisfies node:crypto X509Certificate parse.
|
||||
* - makeMosaicIssuedCert(opts) Cert with custom Mosaic OID extensions — satisfies the
|
||||
* CRIT-1 OID presence + value checks in CaService.issueCert.
|
||||
*/
|
||||
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import {
|
||||
X509CertificateGenerator,
|
||||
Extension,
|
||||
KeyUsagesExtension,
|
||||
KeyUsageFlags,
|
||||
BasicConstraintsExtension,
|
||||
cryptoProvider,
|
||||
} from '@peculiar/x509';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encode a string as an ASN.1 UTF8String TLV:
|
||||
* 0x0C (tag) + 1-byte length (for strings ≤ 127 bytes) + UTF-8 bytes.
|
||||
*
|
||||
* CaService.issueCert reads the extension value as:
|
||||
* decoder.decode(grantIdExt.value.slice(2))
|
||||
* i.e. it skips the tag + length byte and decodes the remainder as UTF-8.
|
||||
* So we must produce exactly this encoding as the OCTET STRING content.
|
||||
*/
|
||||
function encodeUtf8String(value: string): Uint8Array {
|
||||
const utf8 = new TextEncoder().encode(value);
|
||||
if (utf8.length > 127) {
|
||||
throw new Error('encodeUtf8String: value too long for single-byte length encoding');
|
||||
}
|
||||
const buf = new Uint8Array(2 + utf8.length);
|
||||
buf[0] = 0x0c; // ASN.1 UTF8String tag
|
||||
buf[1] = utf8.length;
|
||||
buf.set(utf8, 2);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mosaic OID constants (must match production CaService)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1';
|
||||
const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a minimal self-signed EC P-256 certificate valid for 1 day.
|
||||
* CN=harness-test, no custom extensions.
|
||||
*
|
||||
* Suitable for:
|
||||
* - EnrollmentService.extractCertNotAfter (just needs parseable PEM)
|
||||
* - Any mock that returns certPem / certChainPem without OID checks
|
||||
*/
|
||||
export async function makeSelfSignedCert(): Promise<string> {
|
||||
// Ensure @peculiar/x509 uses Node.js webcrypto (available as globalThis.crypto in Node 19+,
|
||||
// but we set it explicitly here to be safe on all Node 18+ versions).
|
||||
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
|
||||
|
||||
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
|
||||
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
|
||||
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getTime() + 86_400_000);
|
||||
|
||||
const cert = await X509CertificateGenerator.createSelfSigned({
|
||||
serialNumber: '01',
|
||||
name: 'CN=harness-test',
|
||||
notBefore: now,
|
||||
notAfter: tomorrow,
|
||||
signingAlgorithm: alg,
|
||||
keys,
|
||||
extensions: [
|
||||
new BasicConstraintsExtension(false),
|
||||
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
|
||||
],
|
||||
});
|
||||
|
||||
return cert.toString('pem');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a self-signed EC P-256 certificate that contains the two custom
|
||||
* Mosaic OID extensions required by CaService.issueCert's CRIT-1 check:
|
||||
* OID 1.3.6.1.4.1.99999.1 → mosaic_grant_id (value = grantId)
|
||||
* OID 1.3.6.1.4.1.99999.2 → mosaic_subject_user_id (value = subjectUserId)
|
||||
*
|
||||
* The extension value encoding matches the production parser's `.slice(2)` assumption:
|
||||
* each extension value is an OCTET STRING wrapping an ASN.1 UTF8String TLV.
|
||||
*/
|
||||
export async function makeMosaicIssuedCert(opts: {
|
||||
grantId: string;
|
||||
subjectUserId: string;
|
||||
}): Promise<string> {
|
||||
// Ensure @peculiar/x509 uses Node.js webcrypto.
|
||||
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
|
||||
|
||||
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
|
||||
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
|
||||
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getTime() + 86_400_000);
|
||||
|
||||
const cert = await X509CertificateGenerator.createSelfSigned({
|
||||
serialNumber: '01',
|
||||
name: 'CN=mosaic-issued-test',
|
||||
notBefore: now,
|
||||
notAfter: tomorrow,
|
||||
signingAlgorithm: alg,
|
||||
keys,
|
||||
extensions: [
|
||||
new BasicConstraintsExtension(false),
|
||||
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
|
||||
// mosaic_grant_id — OID 1.3.6.1.4.1.99999.1
|
||||
new Extension(OID_MOSAIC_GRANT_ID, false, encodeUtf8String(opts.grantId)),
|
||||
// mosaic_subject_user_id — OID 1.3.6.1.4.1.99999.2
|
||||
new Extension(OID_MOSAIC_SUBJECT_USER_ID, false, encodeUtf8String(opts.subjectUserId)),
|
||||
],
|
||||
});
|
||||
|
||||
return cert.toString('pem');
|
||||
}
|
||||
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;
|
||||
}
|
||||
592
apps/gateway/src/federation/ca.service.spec.ts
Normal file
592
apps/gateway/src/federation/ca.service.spec.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* 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, beforeAll, type Mock } from 'vitest';
|
||||
import { jwtVerify, exportJWK, generateKeyPair } from 'jose';
|
||||
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
|
||||
import { makeMosaicIssuedCert } from './__tests__/helpers/test-cert.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock node:https BEFORE importing CaService so the mock is in place when
|
||||
// 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';
|
||||
|
||||
// Real self-signed cert containing both Mosaic OID extensions — populated in beforeAll.
|
||||
// Required because CaService.issueCert performs CRIT-1 OID presence/value checks on the
|
||||
// response cert (PR #501 — strict parsing, no silent fallback).
|
||||
let realIssuedCertPem: string;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate a real EC P-256 key pair and CSR for integration-style tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Generate a cert with the two Mosaic OIDs so that CaService.issueCert's
|
||||
// CRIT-1 OID checks pass when mock step-ca returns it as `crt`.
|
||||
realIssuedCertPem = await makeMosaicIssuedCert({
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new CaService();
|
||||
});
|
||||
|
||||
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: realIssuedCertPem, certChain: [realIssuedCertPem, FAKE_CA_PEM] });
|
||||
const result = await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
||||
expect(result.certPem).toBe(realIssuedCertPem);
|
||||
});
|
||||
|
||||
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: realIssuedCertPem,
|
||||
certChain: [realIssuedCertPem, FAKE_CA_PEM],
|
||||
});
|
||||
|
||||
const result = await service.issueCert(makeReq());
|
||||
|
||||
expect(result.certPem).toBe(realIssuedCertPem);
|
||||
expect(result.certChainPem).toContain(realIssuedCertPem);
|
||||
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: realIssuedCertPem,
|
||||
ca: FAKE_CA_PEM,
|
||||
});
|
||||
|
||||
const result = await service.issueCert(makeReq());
|
||||
|
||||
expect(result.certPem).toBe(realIssuedCertPem);
|
||||
expect(result.certChainPem).toContain(realIssuedCertPem);
|
||||
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: realIssuedCertPem });
|
||||
|
||||
const result = await service.issueCert(makeReq());
|
||||
|
||||
expect(result.certPem).toBe(realIssuedCertPem);
|
||||
expect(result.certChainPem).toBe(realIssuedCertPem);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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: realIssuedCertPem })));
|
||||
}
|
||||
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: realIssuedCertPem })));
|
||||
}
|
||||
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');
|
||||
});
|
||||
});
|
||||
680
apps/gateway/src/federation/ca.service.ts
Normal file
680
apps/gateway/src/federation/ca.service.ts
Normal file
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* CaService — Step-CA client for federation grant certificate issuance.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with the provisioner
|
||||
* private key (ES256/ES384/RS256 per JWK kty/crv) carrying Mosaic-specific
|
||||
* claims (`mosaic_grant_id`, `mosaic_subject_user_id`, `step.sha`) per the
|
||||
* step-ca JWK provisioner protocol.
|
||||
* 2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS,
|
||||
* pinning the trust to the CA root cert supplied via env.
|
||||
* 3. Return an IssuedCertDto containing the leaf cert, full chain, and
|
||||
* serial number.
|
||||
*
|
||||
* Environment variables (all required at runtime — validated in constructor):
|
||||
* STEP_CA_URL https://step-ca:9000
|
||||
* STEP_CA_PROVISIONER_KEY_JSON JWK provisioner private key (JSON)
|
||||
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
|
||||
*
|
||||
* Optional (only used for JWK PBES2 decrypt at startup if key is encrypted):
|
||||
* STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string)
|
||||
*
|
||||
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
|
||||
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
||||
* 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
|
||||
*
|
||||
* Fail-loud contract:
|
||||
* Every error path throws CaServiceError with a human-readable `remediation`
|
||||
* field. Silent OID-stripping is NEVER allowed — if the sign response does
|
||||
* not include the cert, we throw rather than return a cert that may be
|
||||
* missing the custom extensions.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as https from 'node:https';
|
||||
import { SignJWT, importJWK } from 'jose';
|
||||
import { Pkcs10CertificateRequest, X509Certificate } from '@peculiar/x509';
|
||||
import type { IssueCertRequestDto } from './ca.dto.js';
|
||||
import { IssuedCertDto } from './ca.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom error class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class CaServiceError extends Error {
|
||||
readonly cause: unknown;
|
||||
readonly remediation: string;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, remediation: string, cause?: unknown, code?: string) {
|
||||
super(message);
|
||||
this.name = 'CaServiceError';
|
||||
this.cause = cause;
|
||||
this.remediation = remediation;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StepSignResponse {
|
||||
crt: string;
|
||||
ca?: string;
|
||||
certChain?: string[];
|
||||
}
|
||||
|
||||
interface JwkKey {
|
||||
kty: string;
|
||||
kid?: string;
|
||||
use?: string;
|
||||
alg?: string;
|
||||
k?: string; // symmetric
|
||||
n?: string; // RSA
|
||||
e?: string;
|
||||
d?: string;
|
||||
x?: string; // EC
|
||||
y?: string;
|
||||
crv?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** UUID regex for validation */
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Derive the JWT algorithm string from a JWK's kty/crv fields.
|
||||
* EC P-256 → ES256, EC P-384 → ES384, RSA → RS256.
|
||||
*/
|
||||
function algFromJwk(jwk: JwkKey): string {
|
||||
if (jwk.alg) return jwk.alg;
|
||||
if (jwk.kty === 'EC') {
|
||||
if (jwk.crv === 'P-384') return 'ES384';
|
||||
return 'ES256'; // default for P-256 and Ed25519-style EC keys
|
||||
}
|
||||
if (jwk.kty === 'RSA') return 'RS256';
|
||||
throw new CaServiceError(
|
||||
`Unsupported JWK kty: ${jwk.kty}`,
|
||||
'STEP_CA_PROVISIONER_KEY_JSON must be an EC (P-256/P-384) or RSA JWK private key.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 fingerprint of the DER-encoded CSR body.
|
||||
* step-ca uses this as the `step.sha` claim to bind the OTT to a specific CSR.
|
||||
*/
|
||||
function csrFingerprint(csrPem: string): string {
|
||||
// Strip PEM headers and decode base64 body
|
||||
const b64 = csrPem
|
||||
.replace(/-----BEGIN CERTIFICATE REQUEST-----/, '')
|
||||
.replace(/-----END CERTIFICATE REQUEST-----/, '')
|
||||
.replace(/\s+/g, '');
|
||||
|
||||
let derBuf: Buffer;
|
||||
try {
|
||||
derBuf = Buffer.from(b64, 'base64');
|
||||
} catch (err) {
|
||||
throw new CaServiceError(
|
||||
'Failed to base64-decode the CSR PEM body',
|
||||
'Verify that csrPem is a valid PKCS#10 PEM-encoded certificate request.',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
if (derBuf.length === 0) {
|
||||
throw new CaServiceError(
|
||||
'CSR PEM decoded to empty buffer — malformed input',
|
||||
'Provide a valid non-empty PKCS#10 PEM-encoded certificate request.',
|
||||
);
|
||||
}
|
||||
|
||||
return crypto.createHash('sha256').update(derBuf).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON POST to the step-ca sign endpoint.
|
||||
* Returns the parsed response body or throws CaServiceError.
|
||||
*/
|
||||
function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<StepSignResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const parsed = new URL(url);
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port ? parseInt(parsed.port, 10) : 443,
|
||||
path: parsed.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(bodyStr),
|
||||
},
|
||||
agent,
|
||||
timeout: 5000,
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
reject(
|
||||
new CaServiceError(
|
||||
`step-ca returned HTTP 401 — invalid or expired OTT`,
|
||||
'Check STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode && res.statusCode >= 400) {
|
||||
reject(
|
||||
new CaServiceError(
|
||||
`step-ca returned HTTP ${res.statusCode}: ${raw.slice(0, 256)}`,
|
||||
`Review the step-ca logs. Status ${res.statusCode} may indicate a CSR policy violation or misconfigured provisioner.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch (err) {
|
||||
reject(
|
||||
new CaServiceError(
|
||||
'step-ca returned a non-JSON response',
|
||||
'Verify STEP_CA_URL points to a running step-ca instance and that TLS is properly configured.',
|
||||
err,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(parsed as StepSignResponse);
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(5000, () => {
|
||||
req.destroy(new Error('Request timed out after 5000ms'));
|
||||
});
|
||||
|
||||
req.on('error', (err: Error) => {
|
||||
reject(
|
||||
new CaServiceError(
|
||||
`HTTPS connection to step-ca failed: ${err.message}`,
|
||||
'Ensure STEP_CA_URL is reachable and STEP_CA_ROOT_CERT_PATH points to the correct CA root certificate.',
|
||||
err,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a decimal serial number from a PEM certificate.
|
||||
* Throws CaServiceError on failure — never silently returns 'unknown'.
|
||||
*/
|
||||
function extractSerial(certPem: string): string {
|
||||
let cert: crypto.X509Certificate;
|
||||
try {
|
||||
cert = new crypto.X509Certificate(certPem);
|
||||
} catch (err) {
|
||||
throw new CaServiceError(
|
||||
'Failed to parse the issued certificate PEM',
|
||||
'The certificate returned by step-ca could not be parsed. Check that step-ca is returning a valid PEM certificate.',
|
||||
err,
|
||||
'CERT_PARSE',
|
||||
);
|
||||
}
|
||||
return cert.serialNumber;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable()
|
||||
export class CaService {
|
||||
private readonly logger = new Logger(CaService.name);
|
||||
|
||||
private readonly caUrl: string;
|
||||
private readonly rootCertPath: string;
|
||||
private readonly httpsAgent: https.Agent;
|
||||
private readonly jwk: JwkKey;
|
||||
private cachedPrivateKey: crypto.KeyObject | null = null;
|
||||
private readonly jwtAlg: string;
|
||||
private readonly kid: string;
|
||||
|
||||
constructor() {
|
||||
const caUrl = process.env['STEP_CA_URL'];
|
||||
const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON'];
|
||||
const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||
|
||||
if (!caUrl) {
|
||||
throw new CaServiceError(
|
||||
'STEP_CA_URL is not set',
|
||||
'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce HTTPS-only URL
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(caUrl);
|
||||
} catch (err) {
|
||||
throw new CaServiceError(
|
||||
`STEP_CA_URL is not a valid URL: ${caUrl}`,
|
||||
'Set STEP_CA_URL to a valid HTTPS URL, e.g. https://step-ca:9000',
|
||||
err,
|
||||
);
|
||||
}
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
throw new CaServiceError(
|
||||
`STEP_CA_URL must use HTTPS — got: ${parsedUrl.protocol}`,
|
||||
'Set STEP_CA_URL to an https:// URL. Unencrypted connections to the CA are not permitted.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!provisionerKeyJson) {
|
||||
throw new CaServiceError(
|
||||
'STEP_CA_PROVISIONER_KEY_JSON is not set',
|
||||
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-encoded JWK for the mosaic-fed provisioner.',
|
||||
);
|
||||
}
|
||||
if (!rootCertPath) {
|
||||
throw new CaServiceError(
|
||||
'STEP_CA_ROOT_CERT_PATH is not set',
|
||||
'Set STEP_CA_ROOT_CERT_PATH to the absolute path of the step-ca root CA certificate PEM file.',
|
||||
);
|
||||
}
|
||||
|
||||
// Parse JWK once — do NOT store the raw JSON string as a class field
|
||||
let jwk: JwkKey;
|
||||
try {
|
||||
jwk = JSON.parse(provisionerKeyJson) as JwkKey;
|
||||
} catch (err) {
|
||||
throw new CaServiceError(
|
||||
'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON',
|
||||
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// Derive algorithm from JWK metadata
|
||||
const jwtAlg = algFromJwk(jwk);
|
||||
const kid = jwk.kid ?? 'mosaic-fed';
|
||||
|
||||
// Import the JWK into a native KeyObject — fail loudly if it cannot be loaded.
|
||||
// We do this synchronously here by calling the async importJWK via a blocking workaround.
|
||||
// Actually importJWK is async, so we store it for use during token building.
|
||||
// We keep the raw jwk object for later async import inside buildOtt.
|
||||
// NOTE: We do NOT store provisionerKeyJson string as a class field.
|
||||
this.jwk = jwk;
|
||||
this.jwtAlg = jwtAlg;
|
||||
this.kid = kid;
|
||||
|
||||
this.caUrl = caUrl;
|
||||
this.rootCertPath = rootCertPath;
|
||||
|
||||
// Read the root cert and pin it for all HTTPS connections.
|
||||
let rootCert: string;
|
||||
try {
|
||||
rootCert = fs.readFileSync(this.rootCertPath, 'utf8');
|
||||
} catch (err) {
|
||||
throw new CaServiceError(
|
||||
`Cannot read STEP_CA_ROOT_CERT_PATH: ${rootCertPath}`,
|
||||
'Ensure the file exists and is readable by the gateway process.',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
this.httpsAgent = new https.Agent({
|
||||
ca: rootCert,
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
|
||||
this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily import the private key from JWK on first use.
|
||||
* The key is cached in cachedPrivateKey after first import.
|
||||
*/
|
||||
private async getPrivateKey(): Promise<crypto.KeyObject> {
|
||||
if (this.cachedPrivateKey !== null) return this.cachedPrivateKey;
|
||||
try {
|
||||
const key = await importJWK(this.jwk, this.jwtAlg);
|
||||
// importJWK returns KeyLike (crypto.KeyObject | Uint8Array) — in Node.js it's KeyObject
|
||||
this.cachedPrivateKey = key as unknown as crypto.KeyObject;
|
||||
return this.cachedPrivateKey;
|
||||
} catch (err) {
|
||||
throw new CaServiceError(
|
||||
'Failed to import STEP_CA_PROVISIONER_KEY_JSON as a cryptographic key',
|
||||
'Ensure STEP_CA_PROVISIONER_KEY_JSON contains a valid JWK private key (EC P-256/P-384 or RSA).',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JWK-provisioner OTT signed with the provisioner private key.
|
||||
* Algorithm is derived from the JWK kty/crv fields.
|
||||
*/
|
||||
private async buildOtt(params: {
|
||||
csrPem: string;
|
||||
grantId: string;
|
||||
subjectUserId: string;
|
||||
ttlSeconds: number;
|
||||
csrCn: string;
|
||||
}): Promise<string> {
|
||||
const { csrPem, grantId, subjectUserId, ttlSeconds, csrCn } = params;
|
||||
|
||||
// Validate UUID shape for grant id and subject user id
|
||||
if (!UUID_RE.test(grantId)) {
|
||||
throw new CaServiceError(
|
||||
`grantId is not a valid UUID: ${grantId}`,
|
||||
'Provide a valid UUID (RFC 4122) for grantId.',
|
||||
undefined,
|
||||
'INVALID_GRANT_ID',
|
||||
);
|
||||
}
|
||||
if (!UUID_RE.test(subjectUserId)) {
|
||||
throw new CaServiceError(
|
||||
`subjectUserId is not a valid UUID: ${subjectUserId}`,
|
||||
'Provide a valid UUID (RFC 4122) for subjectUserId.',
|
||||
undefined,
|
||||
'INVALID_GRANT_ID',
|
||||
);
|
||||
}
|
||||
|
||||
const sha = csrFingerprint(csrPem);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const privateKey = await this.getPrivateKey();
|
||||
|
||||
const ott = await new SignJWT({
|
||||
iss: this.kid,
|
||||
sub: csrCn, // M1: set sub to identity from CSR CN
|
||||
aud: [`${this.caUrl}/1.0/sign`],
|
||||
iat: now,
|
||||
nbf: now - 30, // 30 s clock-skew tolerance
|
||||
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
|
||||
jti: crypto.randomUUID(), // M2: unique token ID
|
||||
// step.sha is the canonical field name used in the template — M3: keep only step.sha
|
||||
step: { sha },
|
||||
// Mosaic custom claims consumed by federation.tpl
|
||||
mosaic_grant_id: grantId,
|
||||
mosaic_subject_user_id: subjectUserId,
|
||||
})
|
||||
.setProtectedHeader({ alg: this.jwtAlg, typ: 'JWT', kid: this.kid })
|
||||
.sign(privateKey);
|
||||
|
||||
return ott;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a PEM-encoded CSR using @peculiar/x509.
|
||||
* Verifies the self-signature, key type/size, and signature algorithm.
|
||||
* Optionally verifies that the CSR's SANs match the expected set.
|
||||
*
|
||||
* Throws CaServiceError with code 'INVALID_CSR' on failure.
|
||||
*/
|
||||
private async validateCsr(pem: string, expectedSans?: string[]): Promise<string> {
|
||||
let csr: Pkcs10CertificateRequest;
|
||||
try {
|
||||
csr = new Pkcs10CertificateRequest(pem);
|
||||
} catch (err) {
|
||||
throw new CaServiceError(
|
||||
'Failed to parse CSR PEM as a valid PKCS#10 certificate request',
|
||||
'Provide a valid PEM-encoded PKCS#10 CSR.',
|
||||
err,
|
||||
'INVALID_CSR',
|
||||
);
|
||||
}
|
||||
|
||||
// Verify self-signature
|
||||
let valid: boolean;
|
||||
try {
|
||||
valid = await csr.verify();
|
||||
} catch (err) {
|
||||
throw new CaServiceError(
|
||||
'CSR signature verification threw an error',
|
||||
'The CSR self-signature could not be verified. Ensure the CSR is properly formed.',
|
||||
err,
|
||||
'INVALID_CSR',
|
||||
);
|
||||
}
|
||||
if (!valid) {
|
||||
throw new CaServiceError(
|
||||
'CSR self-signature is invalid',
|
||||
'The CSR must be self-signed with the corresponding private key.',
|
||||
undefined,
|
||||
'INVALID_CSR',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate signature algorithm — reject MD5 and SHA-1
|
||||
// signatureAlgorithm is HashedAlgorithm which extends Algorithm.
|
||||
// Cast through unknown to access .name and .hash.name without DOM lib globals.
|
||||
const sigAlgAny = csr.signatureAlgorithm as unknown as {
|
||||
name?: string;
|
||||
hash?: { name?: string };
|
||||
};
|
||||
const sigAlgName = (sigAlgAny.name ?? '').toLowerCase();
|
||||
const hashName = (sigAlgAny.hash?.name ?? '').toLowerCase();
|
||||
if (
|
||||
sigAlgName.includes('md5') ||
|
||||
sigAlgName.includes('sha1') ||
|
||||
hashName === 'sha-1' ||
|
||||
hashName === 'sha1'
|
||||
) {
|
||||
throw new CaServiceError(
|
||||
`CSR uses a forbidden signature algorithm: ${sigAlgAny.name ?? 'unknown'}`,
|
||||
'Use SHA-256 or stronger. MD5 and SHA-1 are not permitted.',
|
||||
undefined,
|
||||
'INVALID_CSR',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate public key algorithm and strength via the algorithm descriptor on the key.
|
||||
// csr.publicKey.algorithm is type Algorithm (WebCrypto) — use name-based checks.
|
||||
// We cast to an extended interface to access curve/modulus info without DOM globals.
|
||||
const pubKeyAlgo = csr.publicKey.algorithm as {
|
||||
name: string;
|
||||
namedCurve?: string;
|
||||
modulusLength?: number;
|
||||
};
|
||||
const keyAlgoName = pubKeyAlgo.name;
|
||||
|
||||
if (keyAlgoName === 'RSASSA-PKCS1-v1_5' || keyAlgoName === 'RSA-PSS') {
|
||||
const modulusLength = pubKeyAlgo.modulusLength ?? 0;
|
||||
if (modulusLength < 2048) {
|
||||
throw new CaServiceError(
|
||||
`CSR RSA key is too short: ${modulusLength} bits (minimum 2048)`,
|
||||
'Use an RSA key of at least 2048 bits.',
|
||||
undefined,
|
||||
'INVALID_CSR',
|
||||
);
|
||||
}
|
||||
} else if (keyAlgoName === 'ECDSA') {
|
||||
const namedCurve = pubKeyAlgo.namedCurve ?? '';
|
||||
const allowedCurves = new Set(['P-256', 'P-384']);
|
||||
if (!allowedCurves.has(namedCurve)) {
|
||||
throw new CaServiceError(
|
||||
`CSR EC key uses disallowed curve: ${namedCurve}`,
|
||||
'Use EC P-256 or P-384. Other curves are not permitted.',
|
||||
undefined,
|
||||
'INVALID_CSR',
|
||||
);
|
||||
}
|
||||
} else if (keyAlgoName === 'Ed25519') {
|
||||
// Ed25519 is explicitly allowed
|
||||
} else {
|
||||
throw new CaServiceError(
|
||||
`CSR uses unsupported key algorithm: ${keyAlgoName}`,
|
||||
'Use EC (P-256/P-384), Ed25519, or RSA (≥2048 bit) keys.',
|
||||
undefined,
|
||||
'INVALID_CSR',
|
||||
);
|
||||
}
|
||||
|
||||
// Extract SANs if expectedSans provided
|
||||
if (expectedSans && expectedSans.length > 0) {
|
||||
// Get SANs from CSR extensions
|
||||
const sanExtension = csr.extensions?.find(
|
||||
(ext) => ext.type === '2.5.29.17', // Subject Alternative Name OID
|
||||
);
|
||||
const csrSans: string[] = [];
|
||||
if (sanExtension) {
|
||||
// Parse the raw SAN extension — store as stringified for comparison
|
||||
// @peculiar/x509 exposes SANs through the parsed extension
|
||||
const sanExt = sanExtension as { names?: Array<{ type: string; value: string }> };
|
||||
if (sanExt.names) {
|
||||
for (const name of sanExt.names) {
|
||||
csrSans.push(name.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const csrSanSet = new Set(csrSans);
|
||||
const expectedSanSet = new Set(expectedSans);
|
||||
const missing = expectedSans.filter((s) => !csrSanSet.has(s));
|
||||
const extra = csrSans.filter((s) => !expectedSanSet.has(s));
|
||||
|
||||
if (missing.length > 0 || extra.length > 0) {
|
||||
throw new CaServiceError(
|
||||
`CSR SANs do not match expected set. Missing: [${missing.join(', ')}], Extra: [${extra.join(', ')}]`,
|
||||
'The CSR must include exactly the SANs specified in the issuance request.',
|
||||
undefined,
|
||||
'INVALID_CSR',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the CN from the CSR subject for use as JWT sub
|
||||
const cn = csr.subjectName.getField('CN')?.[0] ?? '';
|
||||
return cn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a CSR to step-ca and return the issued certificate.
|
||||
*
|
||||
* Throws `CaServiceError` on any failure (network, auth, malformed input).
|
||||
* Never silently swallows errors — fail-loud is a hard contract per M2-02 review.
|
||||
*/
|
||||
async issueCert(req: IssueCertRequestDto): Promise<IssuedCertDto> {
|
||||
// Clamp TTL to 15-minute maximum (H2)
|
||||
const ttl = Math.min(req.ttlSeconds ?? 300, 900);
|
||||
|
||||
this.logger.debug(
|
||||
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${ttl}s`,
|
||||
);
|
||||
|
||||
// Validate CSR — real cryptographic validation (H3)
|
||||
const csrCn = await this.validateCsr(req.csrPem);
|
||||
|
||||
const ott = await this.buildOtt({
|
||||
csrPem: req.csrPem,
|
||||
grantId: req.grantId,
|
||||
subjectUserId: req.subjectUserId,
|
||||
ttlSeconds: ttl,
|
||||
csrCn,
|
||||
});
|
||||
|
||||
const signUrl = `${this.caUrl}/1.0/sign`;
|
||||
const requestBody = {
|
||||
csr: req.csrPem,
|
||||
ott,
|
||||
validity: {
|
||||
duration: `${ttl}s`,
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.debug(`Posting CSR to ${signUrl}`);
|
||||
const response = await httpsPost(signUrl, requestBody, this.httpsAgent);
|
||||
|
||||
if (!response.crt) {
|
||||
throw new CaServiceError(
|
||||
'step-ca sign response missing the "crt" field',
|
||||
'This is unexpected — the step-ca instance may be misconfigured or running an incompatible version.',
|
||||
);
|
||||
}
|
||||
|
||||
// Build certChainPem: prefer certChain array, fall back to ca field, fall back to crt alone.
|
||||
let certChainPem: string;
|
||||
if (response.certChain && response.certChain.length > 0) {
|
||||
certChainPem = response.certChain.join('\n');
|
||||
} else if (response.ca) {
|
||||
certChainPem = response.crt + '\n' + response.ca;
|
||||
} else {
|
||||
certChainPem = response.crt;
|
||||
}
|
||||
|
||||
const serialNumber = extractSerial(response.crt);
|
||||
|
||||
// CRIT-1: Verify the issued certificate contains both Mosaic OID extensions
|
||||
// with the correct values. Step-CA's federation.tpl encodes each as an ASN.1
|
||||
// UTF8String TLV: tag 0x0C + 1-byte length + UUID bytes. We skip 2 bytes
|
||||
// (tag + length) to extract the raw UUID string.
|
||||
const issuedCert = new X509Certificate(response.crt);
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const grantIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.1');
|
||||
if (!grantIdExt) {
|
||||
throw new CaServiceError(
|
||||
'Issued certificate is missing required Mosaic OID: mosaic_grant_id',
|
||||
'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.1. Check the provisioner template configuration.',
|
||||
undefined,
|
||||
'OID_MISSING',
|
||||
);
|
||||
}
|
||||
const grantIdInCert = decoder.decode(grantIdExt.value.slice(2));
|
||||
if (grantIdInCert !== req.grantId) {
|
||||
throw new CaServiceError(
|
||||
`Issued certificate mosaic_grant_id mismatch: expected ${req.grantId}, got ${grantIdInCert}`,
|
||||
'The Step-CA issued a certificate with a different grant ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
|
||||
undefined,
|
||||
'OID_MISMATCH',
|
||||
);
|
||||
}
|
||||
|
||||
const subjectUserIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.2');
|
||||
if (!subjectUserIdExt) {
|
||||
throw new CaServiceError(
|
||||
'Issued certificate is missing required Mosaic OID: mosaic_subject_user_id',
|
||||
'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.2. Check the provisioner template configuration.',
|
||||
undefined,
|
||||
'OID_MISSING',
|
||||
);
|
||||
}
|
||||
const subjectUserIdInCert = decoder.decode(subjectUserIdExt.value.slice(2));
|
||||
if (subjectUserIdInCert !== req.subjectUserId) {
|
||||
throw new CaServiceError(
|
||||
`Issued certificate mosaic_subject_user_id mismatch: expected ${req.subjectUserId}, got ${subjectUserIdInCert}`,
|
||||
'The Step-CA issued a certificate with a different subject user ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
|
||||
undefined,
|
||||
'OID_MISMATCH',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Certificate issued — serial=${serialNumber} grantId=${req.grantId}`);
|
||||
|
||||
const result = new IssuedCertDto();
|
||||
result.certPem = response.crt;
|
||||
result.certChainPem = certChainPem;
|
||||
result.serialNumber = serialNumber;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* Unit tests for FederationClientService (FED-M3-08).
|
||||
*
|
||||
* HTTP mocking strategy:
|
||||
* undici MockAgent is used to intercept outbound HTTP requests. The service
|
||||
* uses `undici.fetch` with a `dispatcher` option, so MockAgent is set as the
|
||||
* global dispatcher and all requests flow through it.
|
||||
*
|
||||
* Because the service builds one `undici.Agent` per peer and passes it as
|
||||
* the dispatcher on every fetch call, we cannot intercept at the Agent level
|
||||
* in unit tests without significant refactoring. Instead, we set the global
|
||||
* dispatcher to a MockAgent and override the service's `doRequest` indirection
|
||||
* by spying on the internal fetch call.
|
||||
*
|
||||
* For the cert/key wiring, we use the real `sealClientKey` function from
|
||||
* peer-key.util.ts with a test secret — no stubs.
|
||||
*
|
||||
* Sealed-key setup:
|
||||
* Each test (or beforeAll) calls `sealClientKey(TEST_PRIVATE_KEY_PEM)` with
|
||||
* BETTER_AUTH_SECRET set to a deterministic test value so that
|
||||
* `unsealClientKey` in the service recovers the original PEM.
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import { writeFileSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { FederationClientService, FederationClientError } from '../federation-client.service.js';
|
||||
import { sealClientKey } from '../../peer-key.util.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEST_SECRET = 'test-secret-for-federation-client-spec-only';
|
||||
const PEER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
const ENDPOINT = 'https://peer.example.com';
|
||||
|
||||
// Minimal valid RSA/EC private key PEM — does NOT need to be a real key for
|
||||
// unit tests because we only verify it round-trips through seal/unseal, not
|
||||
// that it actually negotiates TLS (MockAgent handles that).
|
||||
const TEST_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDummyKeyForTests
|
||||
-----END PRIVATE KEY-----`;
|
||||
|
||||
// Minimal self-signed cert PEM (dummy — only used for mTLS Agent construction)
|
||||
const TEST_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIBdummyCertForFederationClientTests==
|
||||
-----END CERTIFICATE-----`;
|
||||
|
||||
const TEST_CERT_SERIAL = 'ABCDEF1234567890';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sealed key (computed once in beforeAll)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let SEALED_KEY: string;
|
||||
|
||||
// Path to a stub Step-CA root cert file written in beforeAll. The cert is never
|
||||
// actually used to negotiate TLS in unit tests (MockAgent + spy on resolveEntry
|
||||
// short-circuit the network), but loadStepCaRoot() requires the file to exist.
|
||||
const STUB_CA_PEM_PATH = join(tmpdir(), 'federation-client-spec-ca.pem');
|
||||
const STUB_CA_PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIBdummyCAforFederationClientSpecOnly==
|
||||
-----END CERTIFICATE-----
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Peer row factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makePeerRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: PEER_ID,
|
||||
commonName: 'peer-example-com',
|
||||
displayName: 'Test Peer',
|
||||
certPem: TEST_CERT_PEM,
|
||||
certSerial: TEST_CERT_SERIAL,
|
||||
certNotAfter: new Date('2030-01-01T00:00:00Z'),
|
||||
clientKeyPem: SEALED_KEY,
|
||||
state: 'active' as const,
|
||||
endpointUrl: ENDPOINT,
|
||||
lastSeenAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock DB builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeDb(selectRows: unknown[] = [makePeerRow()]): Db {
|
||||
const limitSelect = vi.fn().mockResolvedValue(selectRows);
|
||||
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||
|
||||
return {
|
||||
select: selectMock,
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for MockAgent HTTP interception
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a MockAgent + MockPool for the peer endpoint, set it as the global
|
||||
* dispatcher, and return both for per-test configuration.
|
||||
*/
|
||||
function makeMockAgent() {
|
||||
const mockAgent = new MockAgent({ connections: 1 });
|
||||
mockAgent.disableNetConnect();
|
||||
setGlobalDispatcher(mockAgent);
|
||||
const pool = mockAgent.get(ENDPOINT);
|
||||
return { mockAgent, pool };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FederationClientService with a mock DB and a spy on the internal
|
||||
* fetch so we can intercept at the HTTP layer via MockAgent.
|
||||
*
|
||||
* The service calls `fetch(url, { dispatcher: agent })` where `agent` is the
|
||||
* mTLS undici.Agent built from the peer's cert+key. To make MockAgent work,
|
||||
* we need the fetch dispatcher to be the MockAgent, not the per-peer Agent.
|
||||
*
|
||||
* Strategy: we replace the private `resolveEntry` result's `agent` field with
|
||||
* the MockAgent's pool, so fetch uses our interceptor. We do this by spying
|
||||
* on `resolveEntry` and returning a controlled entry.
|
||||
*/
|
||||
function makeService(db: Db, mockPool: Dispatcher): FederationClientService {
|
||||
const svc = new FederationClientService(db);
|
||||
|
||||
// Override resolveEntry to inject MockAgent pool as the dispatcher
|
||||
vi.spyOn(
|
||||
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
|
||||
'resolveEntry',
|
||||
).mockImplementation(async (_peerId: string) => {
|
||||
// Still call DB (via the real logic) to exercise peer validation,
|
||||
// but return mock pool as the agent.
|
||||
// For simplicity in unit tests, directly return a controlled entry.
|
||||
return {
|
||||
agent: mockPool,
|
||||
endpointUrl: ENDPOINT,
|
||||
certPem: TEST_CERT_PEM,
|
||||
certSerial: TEST_CERT_SERIAL,
|
||||
};
|
||||
});
|
||||
|
||||
return svc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let originalDispatcher: Dispatcher;
|
||||
|
||||
beforeAll(() => {
|
||||
// Seal the test key once — requires BETTER_AUTH_SECRET
|
||||
const saved = process.env['BETTER_AUTH_SECRET'];
|
||||
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||
try {
|
||||
SEALED_KEY = sealClientKey(TEST_PRIVATE_KEY_PEM);
|
||||
} finally {
|
||||
if (saved === undefined) {
|
||||
delete process.env['BETTER_AUTH_SECRET'];
|
||||
} else {
|
||||
process.env['BETTER_AUTH_SECRET'] = saved;
|
||||
}
|
||||
}
|
||||
writeFileSync(STUB_CA_PEM_PATH, STUB_CA_PEM, 'utf8');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
unlinkSync(STUB_CA_PEM_PATH);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
originalDispatcher = getGlobalDispatcher();
|
||||
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||
process.env['STEP_CA_ROOT_CERT_PATH'] = STUB_CA_PEM_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setGlobalDispatcher(originalDispatcher);
|
||||
vi.restoreAllMocks();
|
||||
delete process.env['BETTER_AUTH_SECRET'];
|
||||
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Successful list response body */
|
||||
const LIST_BODY = {
|
||||
items: [{ id: '1', title: 'Task One' }],
|
||||
nextCursor: undefined,
|
||||
_partial: false,
|
||||
};
|
||||
|
||||
/** Successful get response body */
|
||||
const GET_BODY = {
|
||||
item: { id: '1', title: 'Task One' },
|
||||
_partial: false,
|
||||
};
|
||||
|
||||
/** Successful capabilities response body */
|
||||
const CAP_BODY = {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['list', 'get', 'capabilities'] as const,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationClientService', () => {
|
||||
// ─── Successful verb calls ─────────────────────────────────────────────────
|
||||
|
||||
describe('list()', () => {
|
||||
it('returns parsed typed response on success', async () => {
|
||||
const db = makeDb();
|
||||
const { mockAgent, pool } = makeMockAgent();
|
||||
const svc = makeService(db, pool);
|
||||
|
||||
pool
|
||||
.intercept({
|
||||
path: '/api/federation/v1/list/tasks',
|
||||
method: 'POST',
|
||||
})
|
||||
.reply(200, LIST_BODY, { headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const result = await svc.list(PEER_ID, 'tasks', {});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({ id: '1', title: 'Task One' });
|
||||
|
||||
await mockAgent.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get()', () => {
|
||||
it('returns parsed typed response on success', async () => {
|
||||
const db = makeDb();
|
||||
const { mockAgent, pool } = makeMockAgent();
|
||||
const svc = makeService(db, pool);
|
||||
|
||||
pool
|
||||
.intercept({
|
||||
path: '/api/federation/v1/get/tasks/1',
|
||||
method: 'POST',
|
||||
})
|
||||
.reply(200, GET_BODY, { headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const result = await svc.get(PEER_ID, 'tasks', '1', {});
|
||||
|
||||
expect(result.item).toMatchObject({ id: '1', title: 'Task One' });
|
||||
|
||||
await mockAgent.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilities()', () => {
|
||||
it('returns parsed capabilities response on success', async () => {
|
||||
const db = makeDb();
|
||||
const { mockAgent, pool } = makeMockAgent();
|
||||
const svc = makeService(db, pool);
|
||||
|
||||
pool
|
||||
.intercept({
|
||||
path: '/api/federation/v1/capabilities',
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const result = await svc.capabilities(PEER_ID);
|
||||
|
||||
expect(result.resources).toContain('tasks');
|
||||
expect(result.max_rows_per_query).toBe(100);
|
||||
|
||||
await mockAgent.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── HTTP error surfaces ──────────────────────────────────────────────────
|
||||
|
||||
describe('non-2xx responses', () => {
|
||||
it('surfaces 403 as FederationClientError({ status: 403, code: "FORBIDDEN" })', async () => {
|
||||
const db = makeDb();
|
||||
const { mockAgent, pool } = makeMockAgent();
|
||||
const svc = makeService(db, pool);
|
||||
|
||||
pool.intercept({ path: '/api/federation/v1/list/tasks', method: 'POST' }).reply(
|
||||
403,
|
||||
{ error: { code: 'forbidden', message: 'Access denied' } },
|
||||
{
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
);
|
||||
|
||||
await expect(svc.list(PEER_ID, 'tasks', {})).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: 'FORBIDDEN',
|
||||
peerId: PEER_ID,
|
||||
});
|
||||
|
||||
await mockAgent.close();
|
||||
});
|
||||
|
||||
it('surfaces 404 as FederationClientError({ status: 404, code: "HTTP_404" })', async () => {
|
||||
const db = makeDb();
|
||||
const { mockAgent, pool } = makeMockAgent();
|
||||
const svc = makeService(db, pool);
|
||||
|
||||
pool.intercept({ path: '/api/federation/v1/get/tasks/999', method: 'POST' }).reply(
|
||||
404,
|
||||
{ error: { code: 'not_found', message: 'Not found' } },
|
||||
{
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
);
|
||||
|
||||
await expect(svc.get(PEER_ID, 'tasks', '999', {})).rejects.toMatchObject({
|
||||
status: 404,
|
||||
code: 'HTTP_404',
|
||||
peerId: PEER_ID,
|
||||
});
|
||||
|
||||
await mockAgent.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Network error ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('network errors', () => {
|
||||
it('surfaces network error as FederationClientError({ code: "NETWORK" })', async () => {
|
||||
const db = makeDb();
|
||||
const { mockAgent, pool } = makeMockAgent();
|
||||
const svc = makeService(db, pool);
|
||||
|
||||
pool
|
||||
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||
.replyWithError(new Error('ECONNREFUSED'));
|
||||
|
||||
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||
code: 'NETWORK',
|
||||
peerId: PEER_ID,
|
||||
});
|
||||
|
||||
await mockAgent.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Invalid response body ─────────────────────────────────────────────────
|
||||
|
||||
describe('invalid response body', () => {
|
||||
it('surfaces as FederationClientError({ code: "INVALID_RESPONSE" }) when body shape is wrong', async () => {
|
||||
const db = makeDb();
|
||||
const { mockAgent, pool } = makeMockAgent();
|
||||
const svc = makeService(db, pool);
|
||||
|
||||
// capabilities returns wrong shape (missing required fields)
|
||||
pool
|
||||
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||
.reply(200, { totally: 'wrong' }, { headers: { 'content-type': 'application/json' } });
|
||||
|
||||
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||
code: 'INVALID_RESPONSE',
|
||||
peerId: PEER_ID,
|
||||
});
|
||||
|
||||
await mockAgent.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Peer DB validation ────────────────────────────────────────────────────
|
||||
|
||||
describe('peer validation (without resolveEntry spy)', () => {
|
||||
/**
|
||||
* These tests exercise the real `resolveEntry` path — no spy on resolveEntry.
|
||||
*/
|
||||
|
||||
it('throws PEER_NOT_FOUND when peer is not in DB', async () => {
|
||||
// DB returns empty array (peer not found)
|
||||
const db = makeDb([]);
|
||||
const svc = new FederationClientService(db);
|
||||
|
||||
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||
code: 'PEER_NOT_FOUND',
|
||||
peerId: PEER_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws PEER_INACTIVE when peer state is not "active"', async () => {
|
||||
const db = makeDb([makePeerRow({ state: 'suspended' })]);
|
||||
const svc = new FederationClientService(db);
|
||||
|
||||
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||
code: 'PEER_INACTIVE',
|
||||
peerId: PEER_ID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cache behaviour ───────────────────────────────────────────────────────
|
||||
|
||||
describe('cache behaviour', () => {
|
||||
it('hits cache on second call — only one DB lookup happens', async () => {
|
||||
// Verify cache by calling the private resolveEntry directly twice and
|
||||
// asserting the DB was queried only once. This avoids the HTTP layer,
|
||||
// which would require either a real network or per-peer Agent rewiring
|
||||
// that the cache invariant doesn't depend on.
|
||||
const db = makeDb();
|
||||
const selectSpy = vi.spyOn(db, 'select');
|
||||
const svc = new FederationClientService(db);
|
||||
const resolveEntry = (
|
||||
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> }
|
||||
).resolveEntry.bind(svc);
|
||||
|
||||
const first = await resolveEntry(PEER_ID);
|
||||
const second = await resolveEntry(PEER_ID);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('serializes concurrent resolveEntry calls — only one DB lookup', async () => {
|
||||
const db = makeDb();
|
||||
const selectSpy = vi.spyOn(db, 'select');
|
||||
const svc = new FederationClientService(db);
|
||||
const resolveEntry = (
|
||||
svc as unknown as {
|
||||
resolveEntry: (peerId: string) => Promise<unknown>;
|
||||
}
|
||||
).resolveEntry.bind(svc);
|
||||
|
||||
const [a, b] = await Promise.all([resolveEntry(PEER_ID), resolveEntry(PEER_ID)]);
|
||||
expect(a).toBe(b);
|
||||
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flushPeer destroys the evicted Agent so old TLS connections close', async () => {
|
||||
const db = makeDb();
|
||||
const svc = new FederationClientService(db);
|
||||
const resolveEntry = (
|
||||
svc as unknown as {
|
||||
resolveEntry: (peerId: string) => Promise<{ agent: { destroy: () => Promise<void> } }>;
|
||||
}
|
||||
).resolveEntry.bind(svc);
|
||||
|
||||
const entry = await resolveEntry(PEER_ID);
|
||||
const destroySpy = vi.spyOn(entry.agent, 'destroy').mockResolvedValue();
|
||||
|
||||
svc.flushPeer(PEER_ID);
|
||||
expect(destroySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flushPeer() invalidates cache — next call re-reads DB', async () => {
|
||||
const db = makeDb();
|
||||
const { mockAgent, pool } = makeMockAgent();
|
||||
const svc = makeService(db, pool);
|
||||
|
||||
pool
|
||||
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } })
|
||||
.times(2);
|
||||
|
||||
// First call — populates cache (via mock resolveEntry)
|
||||
await svc.capabilities(PEER_ID);
|
||||
|
||||
// Flush the cache
|
||||
svc.flushPeer(PEER_ID);
|
||||
|
||||
// The spy on resolveEntry is still active — check it's called again after flush
|
||||
const resolveEntrySpy = vi.spyOn(
|
||||
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
|
||||
'resolveEntry',
|
||||
);
|
||||
|
||||
// Second call after flush — should call resolveEntry again
|
||||
await svc.capabilities(PEER_ID);
|
||||
|
||||
// resolveEntry should have been called once after we started spying (post-flush)
|
||||
expect(resolveEntrySpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await mockAgent.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadStepCaRoot env-var guard ─────────────────────────────────────────
|
||||
|
||||
describe('loadStepCaRoot() env-var guard', () => {
|
||||
it('throws PEER_MISCONFIGURED when STEP_CA_ROOT_CERT_PATH is not set', async () => {
|
||||
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||
const db = makeDb();
|
||||
const svc = new FederationClientService(db);
|
||||
const resolveEntry = (
|
||||
svc as unknown as {
|
||||
resolveEntry: (peerId: string) => Promise<unknown>;
|
||||
}
|
||||
).resolveEntry.bind(svc);
|
||||
|
||||
await expect(resolveEntry(PEER_ID)).rejects.toMatchObject({
|
||||
code: 'PEER_MISCONFIGURED',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FederationClientError class ──────────────────────────────────────────
|
||||
|
||||
describe('FederationClientError', () => {
|
||||
it('is instanceof Error and FederationClientError', () => {
|
||||
const err = new FederationClientError({
|
||||
code: 'PEER_NOT_FOUND',
|
||||
message: 'test',
|
||||
peerId: PEER_ID,
|
||||
});
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(FederationClientError);
|
||||
expect(err.name).toBe('FederationClientError');
|
||||
});
|
||||
|
||||
it('carries status, code, and peerId', () => {
|
||||
const err = new FederationClientError({
|
||||
status: 403,
|
||||
code: 'FORBIDDEN',
|
||||
message: 'forbidden',
|
||||
peerId: PEER_ID,
|
||||
});
|
||||
expect(err.status).toBe(403);
|
||||
expect(err.code).toBe('FORBIDDEN');
|
||||
expect(err.peerId).toBe(PEER_ID);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import type { FederationListResponse } from '@mosaicstack/types';
|
||||
import {
|
||||
FederationClientError,
|
||||
type FederationClientService,
|
||||
} from '../federation-client.service.js';
|
||||
import { type QuerySourceError, QuerySourceService } from '../query-source.service.js';
|
||||
|
||||
interface TestRow {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface PeerRow {
|
||||
id: string;
|
||||
commonName: string;
|
||||
endpointUrl: string | null;
|
||||
clientKeyPem: string | null;
|
||||
state: 'active' | 'pending' | 'suspended' | 'revoked';
|
||||
}
|
||||
|
||||
const LOCAL_ROWS: TestRow[] = [
|
||||
{ id: 'local-1', title: 'Local One' },
|
||||
{ id: 'local-2', title: 'Local Two' },
|
||||
];
|
||||
|
||||
const PEER_A: PeerRow = {
|
||||
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
commonName: 'peer-a',
|
||||
endpointUrl: 'https://peer-a.example.com',
|
||||
clientKeyPem: 'sealed-key-a',
|
||||
state: 'active',
|
||||
};
|
||||
|
||||
const PEER_B: PeerRow = {
|
||||
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
commonName: 'peer-b',
|
||||
endpointUrl: 'https://peer-b.example.com',
|
||||
clientKeyPem: 'sealed-key-b',
|
||||
state: 'active',
|
||||
};
|
||||
|
||||
const PEER_LOCALHOST: PeerRow = {
|
||||
id: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
||||
commonName: 'peer-localhost',
|
||||
endpointUrl: 'https://localhost:3001',
|
||||
clientKeyPem: 'sealed-key-c',
|
||||
state: 'active',
|
||||
};
|
||||
|
||||
function makeDb(activePeers: PeerRow[]): Db {
|
||||
const orderBy = vi.fn().mockResolvedValue(activePeers);
|
||||
const where = vi.fn().mockReturnValue({ orderBy });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
|
||||
return {
|
||||
select,
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
} as unknown as Db;
|
||||
}
|
||||
|
||||
function makeFederationClient(
|
||||
list: (
|
||||
peerId: string,
|
||||
resource: string,
|
||||
request: Record<string, unknown>,
|
||||
) => Promise<FederationListResponse<TestRow>>,
|
||||
): FederationClientService {
|
||||
return {
|
||||
list: list as unknown as FederationClientService['list'],
|
||||
} as FederationClientService;
|
||||
}
|
||||
|
||||
function makeLocalResponse(rows: TestRow[] = LOCAL_ROWS): Promise<FederationListResponse<TestRow>> {
|
||||
return Promise.resolve({ items: rows });
|
||||
}
|
||||
|
||||
describe('QuerySourceService', () => {
|
||||
it('routes source="local" to the local executor and tags rows as local', async () => {
|
||||
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
|
||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'local',
|
||||
resource: 'tasks',
|
||||
request: { cursor: 'ignored-for-local-test' },
|
||||
local: () => makeLocalResponse(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
||||
{ id: 'local-2', title: 'Local Two', _source: 'local' },
|
||||
],
|
||||
});
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes source="federated:<host>" to the matching active peer and tags rows with peer commonName', async () => {
|
||||
const list = vi.fn(
|
||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
||||
items: [{ id: 'remote-1', title: 'Remote One' }],
|
||||
}),
|
||||
);
|
||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'federated:peer-b.example.com',
|
||||
resource: 'tasks',
|
||||
request: { status: 'open' },
|
||||
local: () => makeLocalResponse(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 'remote-1', title: 'Remote One', _source: 'peer-b' }],
|
||||
});
|
||||
expect(list).toHaveBeenCalledWith(PEER_B.id, 'tasks', { status: 'open' });
|
||||
});
|
||||
|
||||
it('matches federated hosts by endpoint host including non-default port', async () => {
|
||||
const list = vi.fn(
|
||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
||||
items: [{ id: 'remote-port', title: 'Remote Port' }],
|
||||
}),
|
||||
);
|
||||
const service = new QuerySourceService(makeDb([PEER_LOCALHOST]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'federated:localhost:3001',
|
||||
resource: 'tasks',
|
||||
request: {},
|
||||
local: () => makeLocalResponse(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 'remote-port', title: 'Remote Port', _source: 'peer-localhost' }],
|
||||
});
|
||||
expect(list).toHaveBeenCalledWith(PEER_LOCALHOST.id, 'tasks', {});
|
||||
});
|
||||
|
||||
it('fans out source="all" to local plus every active outbound peer in parallel and merges tagged rows', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
|
||||
callOrder.push(`remote-start:${peerId}`);
|
||||
await Promise.resolve();
|
||||
return {
|
||||
items: [{ id: `remote-${peerId.slice(0, 1)}`, title: `Remote ${peerId.slice(0, 1)}` }],
|
||||
};
|
||||
});
|
||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'all',
|
||||
resource: 'tasks',
|
||||
request: { limit: 25 },
|
||||
local: async () => {
|
||||
callOrder.push('local-start');
|
||||
await Promise.resolve();
|
||||
return { items: [{ id: 'local-1', title: 'Local One' }] };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
||||
{ id: 'remote-a', title: 'Remote a', _source: 'peer-a' },
|
||||
{ id: 'remote-b', title: 'Remote b', _source: 'peer-b' },
|
||||
],
|
||||
});
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
expect(callOrder).toEqual([
|
||||
'local-start',
|
||||
`remote-start:${PEER_A.id}`,
|
||||
`remote-start:${PEER_B.id}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks source="all" as partial and truncated when any subquery returns a cursor', async () => {
|
||||
const list = vi.fn(
|
||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
||||
items: [{ id: 'remote-a', title: 'Remote A' }],
|
||||
nextCursor: 'remote-next',
|
||||
}),
|
||||
);
|
||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'all',
|
||||
resource: 'tasks',
|
||||
request: {},
|
||||
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
||||
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
|
||||
],
|
||||
_partial: true,
|
||||
_truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns _partial=true for source="all" when one peer fails without dropping successful sources', async () => {
|
||||
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
|
||||
if (peerId === PEER_B.id) {
|
||||
throw new FederationClientError({
|
||||
code: 'NETWORK',
|
||||
message: 'peer unavailable',
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
return { items: [{ id: 'remote-a', title: 'Remote A' }] };
|
||||
});
|
||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
||||
|
||||
const result = await service.list<TestRow>({
|
||||
source: 'all',
|
||||
resource: 'tasks',
|
||||
request: {},
|
||||
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
||||
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
|
||||
],
|
||||
_partial: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws QuerySourceError when a federated host does not match an active outbound peer', async () => {
|
||||
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
|
||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
||||
|
||||
await expect(
|
||||
service.list<TestRow>({
|
||||
source: 'federated:missing.example.com',
|
||||
resource: 'tasks',
|
||||
request: {},
|
||||
local: () => makeLocalResponse(),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'QuerySourceError',
|
||||
code: 'PEER_NOT_FOUND',
|
||||
} satisfies Partial<QuerySourceError>);
|
||||
});
|
||||
});
|
||||
500
apps/gateway/src/federation/client/federation-client.service.ts
Normal file
500
apps/gateway/src/federation/client/federation-client.service.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* FederationClientService — outbound mTLS client for federation requests (FED-M3-08).
|
||||
*
|
||||
* Dials peer gateways over mTLS using the cert+sealed-key stored in `federation_peers`,
|
||||
* invokes federation verbs (list / get / capabilities), and surfaces all failure modes
|
||||
* as typed `FederationClientError` instances.
|
||||
*
|
||||
* ## Error code taxonomy
|
||||
*
|
||||
* | Code | When |
|
||||
* | ------------------ | ------------------------------------------------------------- |
|
||||
* | PEER_NOT_FOUND | No row in federation_peers for the given peerId |
|
||||
* | PEER_INACTIVE | Peer row exists but state !== 'active' |
|
||||
* | PEER_MISCONFIGURED | Peer row is active but missing endpointUrl or clientKeyPem |
|
||||
* | NETWORK | undici threw a connection / TLS / timeout error |
|
||||
* | HTTP_{status} | Peer returned a non-2xx response (e.g. HTTP_403, HTTP_404) |
|
||||
* | FORBIDDEN | Peer returned 403 (convenience alias alongside HTTP_403) |
|
||||
* | INVALID_RESPONSE | Response body failed Zod schema validation |
|
||||
*
|
||||
* ## Cache strategy
|
||||
*
|
||||
* Per-peer `undici.Agent` instances are cached in a `Map<peerId, AgentCacheEntry>` for
|
||||
* the lifetime of the service instance. The cache is keyed on peerId (UUID).
|
||||
*
|
||||
* Cache invalidation:
|
||||
* - `flushPeer(peerId)` — removes the entry immediately. M5/M6 MUST call this on
|
||||
* cert rotation or peer revocation events so the next request re-reads the DB and
|
||||
* builds a fresh TLS Agent with the new cert material.
|
||||
* - On cache miss: re-reads the DB, checks state === 'active', rebuilds Agent.
|
||||
*
|
||||
* Cache does NOT auto-expire. The service is expected to be a singleton scoped to the
|
||||
* NestJS application lifecycle; flushing on revocation/rotation is the only invalidation
|
||||
* path by design (avoids redundant DB round-trips on the hot path).
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import { z } from 'zod';
|
||||
import { type Db, eq, federationPeers } from '@mosaicstack/db';
|
||||
import {
|
||||
FederationListResponseSchema,
|
||||
FederationGetResponseSchema,
|
||||
FederationCapabilitiesResponseSchema,
|
||||
FederationErrorEnvelopeSchema,
|
||||
type FederationListResponse,
|
||||
type FederationGetResponse,
|
||||
type FederationCapabilitiesResponse,
|
||||
} from '@mosaicstack/types';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { unsealClientKey } from '../peer-key.util.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error taxonomy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Client-side error code set. Distinct from the server-side `FederationErrorCode`
|
||||
* (which lives in `@mosaicstack/types`) because the client has additional failure
|
||||
* modes (PEER_NOT_FOUND, PEER_INACTIVE, PEER_MISCONFIGURED, NETWORK) that the
|
||||
* server never emits.
|
||||
*/
|
||||
export type FederationClientErrorCode =
|
||||
| 'PEER_NOT_FOUND'
|
||||
| 'PEER_INACTIVE'
|
||||
| 'PEER_MISCONFIGURED'
|
||||
| 'NETWORK'
|
||||
| 'FORBIDDEN'
|
||||
| 'INVALID_RESPONSE'
|
||||
| `HTTP_${number}`;
|
||||
|
||||
export interface FederationClientErrorOptions {
|
||||
status?: number;
|
||||
code: FederationClientErrorCode;
|
||||
message: string;
|
||||
peerId: string;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by FederationClientService on every failure path.
|
||||
* Callers can dispatch on `error.code` for programmatic handling.
|
||||
*/
|
||||
export class FederationClientError extends Error {
|
||||
readonly status?: number;
|
||||
readonly code: FederationClientErrorCode;
|
||||
readonly peerId: string;
|
||||
readonly cause?: unknown;
|
||||
|
||||
constructor(opts: FederationClientErrorOptions) {
|
||||
super(opts.message);
|
||||
this.name = 'FederationClientError';
|
||||
this.status = opts.status;
|
||||
this.code = opts.code;
|
||||
this.peerId = opts.peerId;
|
||||
this.cause = opts.cause;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal cache types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AgentCacheEntry {
|
||||
agent: Agent;
|
||||
endpointUrl: string;
|
||||
certPem: string;
|
||||
certSerial: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable()
|
||||
export class FederationClientService {
|
||||
private readonly logger = new Logger(FederationClientService.name);
|
||||
|
||||
/**
|
||||
* Per-peer undici Agent cache.
|
||||
* Key = peerId (UUID string).
|
||||
*
|
||||
* Values are either a resolved `AgentCacheEntry` or an in-flight
|
||||
* `Promise<AgentCacheEntry>` (promise-cache pattern). Storing the promise
|
||||
* prevents duplicate DB lookups and duplicate key-unseal operations when two
|
||||
* requests for the same peer arrive before the first build completes.
|
||||
*
|
||||
* Flush via `flushPeer(peerId)` on cert rotation / peer revocation (M5/M6).
|
||||
*/
|
||||
private readonly cache = new Map<string, AgentCacheEntry | Promise<AgentCacheEntry>>();
|
||||
|
||||
/**
|
||||
* Step-CA root cert PEM, loaded once from `STEP_CA_ROOT_CERT_PATH`.
|
||||
* Used as the trust anchor for peer server certificates so federation TLS is
|
||||
* pinned to our PKI, not the public trust store. Lazily loaded on first use
|
||||
* so unit tests that don't exercise the agent path can run without the env var.
|
||||
*/
|
||||
private cachedCaPem: string | null = null;
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public verb API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Invoke the `list` verb on a remote peer.
|
||||
*
|
||||
* @param peerId UUID of the peer row in `federation_peers`.
|
||||
* @param resource Resource path, e.g. "tasks".
|
||||
* @param request Free-form body sent as JSON in the POST body.
|
||||
* @returns Parsed `FederationListResponse<T>`.
|
||||
*/
|
||||
async list<T>(
|
||||
peerId: string,
|
||||
resource: string,
|
||||
request: Record<string, unknown>,
|
||||
): Promise<FederationListResponse<T>> {
|
||||
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||
const url = `${endpointUrl}/api/federation/v1/list/${encodeURIComponent(resource)}`;
|
||||
const body = await this.doPost(peerId, url, agent, request);
|
||||
return this.parseWith<FederationListResponse<T>>(
|
||||
peerId,
|
||||
body,
|
||||
FederationListResponseSchema(z.unknown()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the `get` verb on a remote peer.
|
||||
*
|
||||
* @param peerId UUID of the peer row in `federation_peers`.
|
||||
* @param resource Resource path, e.g. "tasks".
|
||||
* @param id Resource identifier.
|
||||
* @param request Free-form body sent as JSON in the POST body.
|
||||
* @returns Parsed `FederationGetResponse<T>`.
|
||||
*/
|
||||
async get<T>(
|
||||
peerId: string,
|
||||
resource: string,
|
||||
id: string,
|
||||
request: Record<string, unknown>,
|
||||
): Promise<FederationGetResponse<T>> {
|
||||
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||
const url = `${endpointUrl}/api/federation/v1/get/${encodeURIComponent(resource)}/${encodeURIComponent(id)}`;
|
||||
const body = await this.doPost(peerId, url, agent, request);
|
||||
return this.parseWith<FederationGetResponse<T>>(
|
||||
peerId,
|
||||
body,
|
||||
FederationGetResponseSchema(z.unknown()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the `capabilities` verb on a remote peer.
|
||||
*
|
||||
* @param peerId UUID of the peer row in `federation_peers`.
|
||||
* @returns Parsed `FederationCapabilitiesResponse`.
|
||||
*/
|
||||
async capabilities(peerId: string): Promise<FederationCapabilitiesResponse> {
|
||||
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||
const url = `${endpointUrl}/api/federation/v1/capabilities`;
|
||||
const body = await this.doGet(peerId, url, agent);
|
||||
return this.parseWith<FederationCapabilitiesResponse>(
|
||||
peerId,
|
||||
body,
|
||||
FederationCapabilitiesResponseSchema,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cache management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Flush the cached Agent for a specific peer.
|
||||
*
|
||||
* M5/M6 MUST call this on:
|
||||
* - cert rotation events (so new cert material is picked up)
|
||||
* - peer revocation events (so future requests fail at PEER_INACTIVE)
|
||||
*
|
||||
* After flushing, the next call to `list`, `get`, or `capabilities` for
|
||||
* this peer will re-read the DB and rebuild the Agent.
|
||||
*/
|
||||
flushPeer(peerId: string): void {
|
||||
const entry = this.cache.get(peerId);
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
this.cache.delete(peerId);
|
||||
if (!(entry instanceof Promise)) {
|
||||
// best-effort destroy; promise-cached entries skip destroy because
|
||||
// the in-flight build owns its own Agent which will be GC'd when the
|
||||
// owning request handles the rejection from the cache miss
|
||||
entry.agent.destroy().catch(() => {
|
||||
// intentionally ignored — destroy errors are not actionable
|
||||
});
|
||||
}
|
||||
this.logger.log(`Cache flushed for peer ${peerId}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load and cache the Step-CA root cert PEM from `STEP_CA_ROOT_CERT_PATH`.
|
||||
* Throws `FederationClientError` if the env var is unset or the file cannot
|
||||
* be read — mTLS to a peer without a pinned trust anchor would silently
|
||||
* fall back to the public trust store.
|
||||
*/
|
||||
private loadStepCaRoot(): string {
|
||||
if (this.cachedCaPem !== null) {
|
||||
return this.cachedCaPem;
|
||||
}
|
||||
const path = process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||
if (!path) {
|
||||
throw new FederationClientError({
|
||||
code: 'PEER_MISCONFIGURED',
|
||||
message: 'STEP_CA_ROOT_CERT_PATH is not set; refusing to dial peer without pinned CA trust',
|
||||
peerId: '',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const pem = readFileSync(path, 'utf8');
|
||||
this.cachedCaPem = pem;
|
||||
return pem;
|
||||
} catch (err) {
|
||||
throw new FederationClientError({
|
||||
code: 'PEER_MISCONFIGURED',
|
||||
message: `Failed to read STEP_CA_ROOT_CERT_PATH (${path})`,
|
||||
peerId: '',
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the cache entry for a peer, reading DB on miss.
|
||||
*
|
||||
* Uses a promise-cache pattern: concurrent callers for the same uncached
|
||||
* `peerId` all `await` the same in-flight `Promise<AgentCacheEntry>` so
|
||||
* only one DB lookup and one key-unseal ever runs per peer per cache miss.
|
||||
* The promise is replaced with the concrete entry on success, or deleted on
|
||||
* rejection so a transient error does not poison the cache permanently.
|
||||
*
|
||||
* Throws `FederationClientError` with appropriate code if the peer is not
|
||||
* found, is inactive, or is missing required fields.
|
||||
*/
|
||||
private async resolveEntry(peerId: string): Promise<AgentCacheEntry> {
|
||||
const cached = this.cache.get(peerId);
|
||||
if (cached) {
|
||||
return cached; // Promise or concrete entry — both are awaitable
|
||||
}
|
||||
|
||||
const inflight = this.buildEntry(peerId).then(
|
||||
(entry) => {
|
||||
this.cache.set(peerId, entry); // replace promise with concrete value
|
||||
return entry;
|
||||
},
|
||||
(err: unknown) => {
|
||||
this.cache.delete(peerId); // don't poison the cache with a rejected promise
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
|
||||
this.cache.set(peerId, inflight);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `AgentCacheEntry` for a peer by reading the DB, validating the
|
||||
* peer's state, unsealing the private key, and constructing the mTLS Agent.
|
||||
*
|
||||
* Throws `FederationClientError` with appropriate code if the peer is not
|
||||
* found, is inactive, or is missing required fields.
|
||||
*/
|
||||
private async buildEntry(peerId: string): Promise<AgentCacheEntry> {
|
||||
// DB lookup
|
||||
const [peer] = await this.db
|
||||
.select()
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, peerId))
|
||||
.limit(1);
|
||||
|
||||
if (!peer) {
|
||||
throw new FederationClientError({
|
||||
code: 'PEER_NOT_FOUND',
|
||||
message: `Federation peer ${peerId} not found`,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
|
||||
if (peer.state !== 'active') {
|
||||
throw new FederationClientError({
|
||||
code: 'PEER_INACTIVE',
|
||||
message: `Federation peer ${peerId} is not active (state: ${peer.state})`,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!peer.endpointUrl || !peer.clientKeyPem) {
|
||||
throw new FederationClientError({
|
||||
code: 'PEER_MISCONFIGURED',
|
||||
message: `Federation peer ${peerId} is missing endpointUrl or clientKeyPem`,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Unseal the private key
|
||||
let privateKeyPem: string;
|
||||
try {
|
||||
privateKeyPem = unsealClientKey(peer.clientKeyPem);
|
||||
} catch (err) {
|
||||
throw new FederationClientError({
|
||||
code: 'PEER_MISCONFIGURED',
|
||||
message: `Failed to unseal client key for peer ${peerId}`,
|
||||
peerId,
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
// Build mTLS agent — pin trust to Step-CA root so we never accept
|
||||
// a peer cert signed by a public CA (defense against MITM with a
|
||||
// publicly-trusted DV cert for the peer's hostname).
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
cert: peer.certPem,
|
||||
key: privateKeyPem,
|
||||
ca: this.loadStepCaRoot(),
|
||||
// rejectUnauthorized: true is the undici default for HTTPS
|
||||
},
|
||||
});
|
||||
|
||||
const entry: AgentCacheEntry = {
|
||||
agent,
|
||||
endpointUrl: peer.endpointUrl,
|
||||
certPem: peer.certPem,
|
||||
certSerial: peer.certSerial,
|
||||
};
|
||||
|
||||
this.logger.log(`Agent cached for peer ${peerId} (serial: ${peer.certSerial})`);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a POST request with a JSON body.
|
||||
* Returns the parsed response body as an unknown value.
|
||||
* Throws `FederationClientError` on network errors and non-2xx responses.
|
||||
*/
|
||||
private async doPost(
|
||||
peerId: string,
|
||||
url: string,
|
||||
agent: Dispatcher,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return this.doRequest(peerId, url, agent, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a GET request.
|
||||
* Returns the parsed response body as an unknown value.
|
||||
* Throws `FederationClientError` on network errors and non-2xx responses.
|
||||
*/
|
||||
private async doGet(peerId: string, url: string, agent: Dispatcher): Promise<unknown> {
|
||||
return this.doRequest(peerId, url, agent, { method: 'GET' });
|
||||
}
|
||||
|
||||
private async doRequest(
|
||||
peerId: string,
|
||||
url: string,
|
||||
agent: Dispatcher,
|
||||
init: { method: string; headers?: Record<string, string>; body?: string },
|
||||
): Promise<unknown> {
|
||||
let response: Awaited<ReturnType<typeof undiciFetch>>;
|
||||
|
||||
try {
|
||||
response = await undiciFetch(url, {
|
||||
...init,
|
||||
dispatcher: agent,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new FederationClientError({
|
||||
code: 'NETWORK',
|
||||
message: `Network error calling peer ${peerId} at ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
peerId,
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await response.text().catch(() => '');
|
||||
|
||||
if (!response.ok) {
|
||||
const status = response.status;
|
||||
|
||||
// Attempt to parse as federation error envelope
|
||||
let serverMessage = `HTTP ${status}`;
|
||||
try {
|
||||
const json: unknown = JSON.parse(rawBody);
|
||||
const result = FederationErrorEnvelopeSchema.safeParse(json);
|
||||
if (result.success) {
|
||||
serverMessage = result.data.error.message;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON or not a federation envelope — use generic message
|
||||
}
|
||||
|
||||
// Specific code for 403 (most actionable for callers); generic HTTP_{n} for others
|
||||
const code: FederationClientErrorCode = status === 403 ? 'FORBIDDEN' : `HTTP_${status}`;
|
||||
|
||||
throw new FederationClientError({
|
||||
status,
|
||||
code,
|
||||
message: `Peer ${peerId} returned ${status}: ${serverMessage}`,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawBody) as unknown;
|
||||
} catch (err) {
|
||||
throw new FederationClientError({
|
||||
code: 'INVALID_RESPONSE',
|
||||
message: `Peer ${peerId} returned non-JSON body`,
|
||||
peerId,
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a response body against a Zod schema.
|
||||
*
|
||||
* For list/get, callers pass the result of `FederationListResponseSchema(z.unknown())`
|
||||
* so that the envelope structure is validated without requiring a concrete item schema
|
||||
* at the client level. The generic `T` provides compile-time typing.
|
||||
*
|
||||
* Throws `FederationClientError({ code: 'INVALID_RESPONSE' })` on parse failure.
|
||||
*/
|
||||
private parseWith<T>(peerId: string, body: unknown, schema: z.ZodTypeAny): T {
|
||||
const result = schema.safeParse(body);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((e: z.ZodIssue) => `[${e.path.join('.') || 'root'}] ${e.message}`)
|
||||
.join('; ');
|
||||
throw new FederationClientError({
|
||||
code: 'INVALID_RESPONSE',
|
||||
message: `Peer ${peerId} returned invalid response shape: ${issues}`,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
return result.data as T;
|
||||
}
|
||||
}
|
||||
23
apps/gateway/src/federation/client/index.ts
Normal file
23
apps/gateway/src/federation/client/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Federation client barrel — re-exports for FederationModule consumers.
|
||||
*
|
||||
* M3-09 (QuerySourceService) and future milestones should import from here,
|
||||
* not directly from the implementation file.
|
||||
*/
|
||||
|
||||
export {
|
||||
FederationClientService,
|
||||
FederationClientError,
|
||||
type FederationClientErrorCode,
|
||||
type FederationClientErrorOptions,
|
||||
} from './federation-client.service.js';
|
||||
export {
|
||||
QuerySourceService,
|
||||
QuerySourceError,
|
||||
type QuerySource,
|
||||
type QuerySourceErrorCode,
|
||||
type QuerySourceErrorOptions,
|
||||
type QuerySourceListOptions,
|
||||
type QuerySourceListResponse,
|
||||
type LocalListExecutor,
|
||||
} from './query-source.service.js';
|
||||
261
apps/gateway/src/federation/client/query-source.service.ts
Normal file
261
apps/gateway/src/federation/client/query-source.service.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* QuerySourceService — gateway query source router (FED-M3-09).
|
||||
*
|
||||
* Accepts the federation query-layer `source` selector and routes list-style
|
||||
* reads to local storage, one federated peer, or all active outbound peers.
|
||||
*
|
||||
* `source: "all"` is intentionally tolerant of per-peer failures: local data
|
||||
* and successful peer responses are returned, and the envelope is marked
|
||||
* `_partial: true`. Local failures still reject because there is no safe local
|
||||
* fallback and the gateway's own storage is expected to be authoritative.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { and, eq, federationPeers, isNotNull, type Db } from '@mosaicstack/db';
|
||||
import {
|
||||
SOURCE_LOCAL,
|
||||
tagWithSource,
|
||||
type FederationListResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { FederationClientService } from './federation-client.service.js';
|
||||
|
||||
export type QuerySource = 'local' | 'all' | `federated:${string}`;
|
||||
|
||||
export type QuerySourceErrorCode = 'INVALID_SOURCE' | 'PEER_NOT_FOUND';
|
||||
|
||||
export interface QuerySourceErrorOptions {
|
||||
code: QuerySourceErrorCode;
|
||||
message: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export class QuerySourceError extends Error {
|
||||
readonly code: QuerySourceErrorCode;
|
||||
readonly source: string;
|
||||
|
||||
constructor(opts: QuerySourceErrorOptions) {
|
||||
super(opts.message);
|
||||
this.name = 'QuerySourceError';
|
||||
this.code = opts.code;
|
||||
this.source = opts.source;
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalListExecutor<T extends object> = () => Promise<FederationListResponse<T> | T[]>;
|
||||
|
||||
export interface QuerySourceListOptions<T extends object> {
|
||||
source: QuerySource;
|
||||
resource: string;
|
||||
request?: Record<string, unknown>;
|
||||
local: LocalListExecutor<T>;
|
||||
}
|
||||
|
||||
export type QuerySourceListResponse<T extends object> = FederationListResponse<T & SourceTag>;
|
||||
|
||||
interface OutboundPeer {
|
||||
id: string;
|
||||
commonName: string;
|
||||
endpointUrl: string;
|
||||
}
|
||||
|
||||
interface TaggedList<T extends object> {
|
||||
items: Array<T & SourceTag>;
|
||||
partial: boolean;
|
||||
truncated: boolean;
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuerySourceService {
|
||||
private readonly logger = new Logger(QuerySourceService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(FederationClientService) private readonly federationClient: FederationClientService,
|
||||
) {}
|
||||
|
||||
async list<T extends object>(
|
||||
options: QuerySourceListOptions<T>,
|
||||
): Promise<QuerySourceListResponse<T>> {
|
||||
const request = options.request ?? {};
|
||||
|
||||
if (options.source === 'local') {
|
||||
const local = await this.runLocal(options.local);
|
||||
return this.toResponse(this.tagList(local, SOURCE_LOCAL));
|
||||
}
|
||||
|
||||
if (options.source === 'all') {
|
||||
return this.listAll(options.resource, request, options.local);
|
||||
}
|
||||
|
||||
if (options.source.startsWith('federated:')) {
|
||||
const host = options.source.slice('federated:'.length).trim();
|
||||
if (!host) {
|
||||
throw new QuerySourceError({
|
||||
code: 'INVALID_SOURCE',
|
||||
message: 'Federated source must include a host after federated:',
|
||||
source: options.source,
|
||||
});
|
||||
}
|
||||
|
||||
const peer = await this.findPeerByHost(host, options.source);
|
||||
const remote = await this.federationClient.list<T>(peer.id, options.resource, request);
|
||||
return this.toResponse(this.tagList(remote, peer.commonName));
|
||||
}
|
||||
|
||||
throw new QuerySourceError({
|
||||
code: 'INVALID_SOURCE',
|
||||
message: `Unsupported query source: ${options.source}`,
|
||||
source: options.source,
|
||||
});
|
||||
}
|
||||
|
||||
private async listAll<T extends object>(
|
||||
resource: string,
|
||||
request: Record<string, unknown>,
|
||||
local: LocalListExecutor<T>,
|
||||
): Promise<QuerySourceListResponse<T>> {
|
||||
const peers = await this.listActiveOutboundPeers();
|
||||
|
||||
const localPromise = this.runLocal(local).then((response) =>
|
||||
this.tagList(response, SOURCE_LOCAL),
|
||||
);
|
||||
const remotePromises = peers.map(async (peer: OutboundPeer): Promise<TaggedList<T> | null> => {
|
||||
try {
|
||||
const response = await this.federationClient.list<T>(peer.id, resource, request);
|
||||
return this.tagList(response, peer.commonName);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Federated query to peer ${peer.commonName} (${peer.id}) failed; returning partial all-source response: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const [localResult, ...remoteResults] = await Promise.all([localPromise, ...remotePromises]);
|
||||
const successfulRemoteResults = remoteResults.filter(
|
||||
(result: TaggedList<T> | null): result is TaggedList<T> => result !== null,
|
||||
);
|
||||
const allResults = [localResult, ...successfulRemoteResults];
|
||||
const peerFailure = successfulRemoteResults.length !== peers.length;
|
||||
|
||||
return this.mergeTaggedLists(allResults, peerFailure);
|
||||
}
|
||||
|
||||
private async runLocal<T extends object>(
|
||||
local: LocalListExecutor<T>,
|
||||
): Promise<FederationListResponse<T>> {
|
||||
const response = await local();
|
||||
if (Array.isArray(response)) {
|
||||
return { items: response };
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private tagList<T extends object>(
|
||||
response: FederationListResponse<T>,
|
||||
source: string,
|
||||
): TaggedList<T> {
|
||||
return {
|
||||
items: tagWithSource(response.items, source),
|
||||
partial: response._partial === true,
|
||||
truncated: response._truncated === true || response.nextCursor !== undefined,
|
||||
nextCursor: response.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
private mergeTaggedLists<T extends object>(
|
||||
lists: Array<TaggedList<T>>,
|
||||
peerFailure: boolean,
|
||||
): QuerySourceListResponse<T> {
|
||||
const items = lists.flatMap((list: TaggedList<T>) => list.items);
|
||||
const partial =
|
||||
peerFailure ||
|
||||
lists.some((list: TaggedList<T>) => list.partial || list.nextCursor !== undefined);
|
||||
const truncated = lists.some((list: TaggedList<T>) => list.truncated);
|
||||
|
||||
const response: QuerySourceListResponse<T> = { items };
|
||||
if (partial) {
|
||||
response._partial = true;
|
||||
}
|
||||
if (truncated) {
|
||||
response._truncated = true;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private toResponse<T extends object>(tagged: TaggedList<T>): QuerySourceListResponse<T> {
|
||||
const response: QuerySourceListResponse<T> = {
|
||||
items: tagged.items,
|
||||
};
|
||||
if (tagged.nextCursor !== undefined) {
|
||||
response.nextCursor = tagged.nextCursor;
|
||||
}
|
||||
if (tagged.partial) {
|
||||
response._partial = true;
|
||||
}
|
||||
if (tagged.truncated) {
|
||||
response._truncated = true;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async findPeerByHost(sourceHost: string, source: string): Promise<OutboundPeer> {
|
||||
const host = normalizeHost(sourceHost);
|
||||
const peers = await this.listActiveOutboundPeers();
|
||||
const peer = peers.find((candidate: OutboundPeer) => {
|
||||
const commonName = normalizeHost(candidate.commonName);
|
||||
const endpointHosts = endpointHostKeys(candidate.endpointUrl).map((endpointHost: string) =>
|
||||
normalizeHost(endpointHost),
|
||||
);
|
||||
return commonName === host || endpointHosts.includes(host);
|
||||
});
|
||||
|
||||
if (!peer) {
|
||||
throw new QuerySourceError({
|
||||
code: 'PEER_NOT_FOUND',
|
||||
message: `No active outbound federation peer matches source ${source}`,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
return peer;
|
||||
}
|
||||
|
||||
private async listActiveOutboundPeers(): Promise<OutboundPeer[]> {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: federationPeers.id,
|
||||
commonName: federationPeers.commonName,
|
||||
endpointUrl: federationPeers.endpointUrl,
|
||||
})
|
||||
.from(federationPeers)
|
||||
.where(
|
||||
and(
|
||||
eq(federationPeers.state, 'active'),
|
||||
isNotNull(federationPeers.endpointUrl),
|
||||
isNotNull(federationPeers.clientKeyPem),
|
||||
),
|
||||
)
|
||||
.orderBy(federationPeers.commonName);
|
||||
|
||||
return rows.filter((row): row is OutboundPeer => typeof row.endpointUrl === 'string');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function endpointHostKeys(endpointUrl: string): string[] {
|
||||
try {
|
||||
const url = new URL(endpointUrl);
|
||||
return Array.from(new Set([url.host, url.hostname].filter((host: string) => host.length > 0)));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
54
apps/gateway/src/federation/enrollment.controller.ts
Normal file
54
apps/gateway/src/federation/enrollment.controller.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* EnrollmentController — federation enrollment HTTP layer (FED-M2-07).
|
||||
*
|
||||
* Routes:
|
||||
* POST /api/federation/enrollment/tokens — admin creates a single-use token
|
||||
* POST /api/federation/enrollment/:token — unauthenticated; token IS the auth
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { CreateEnrollmentTokenDto, RedeemEnrollmentTokenDto } from './enrollment.dto.js';
|
||||
|
||||
@Controller('api/federation/enrollment')
|
||||
export class EnrollmentController {
|
||||
constructor(@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService) {}
|
||||
|
||||
/**
|
||||
* Admin-only: generate a single-use enrollment token for a pending grant.
|
||||
* The token should be distributed out-of-band to the remote peer operator.
|
||||
*
|
||||
* POST /api/federation/enrollment/tokens
|
||||
*/
|
||||
@Post('tokens')
|
||||
@UseGuards(AdminGuard)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createToken(@Body() dto: CreateEnrollmentTokenDto) {
|
||||
return this.enrollmentService.createToken(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticated: remote peer redeems a token by submitting its CSR.
|
||||
* The token itself is the credential — no session or bearer token required.
|
||||
*
|
||||
* POST /api/federation/enrollment/:token
|
||||
*
|
||||
* Returns the signed leaf cert and full chain PEM on success.
|
||||
* Returns 410 Gone if the token was already used or has expired.
|
||||
*/
|
||||
@Post(':token')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async redeem(@Param('token') token: string, @Body() dto: RedeemEnrollmentTokenDto) {
|
||||
return this.enrollmentService.redeem(token, dto.csrPem);
|
||||
}
|
||||
}
|
||||
35
apps/gateway/src/federation/enrollment.dto.ts
Normal file
35
apps/gateway/src/federation/enrollment.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* DTOs for the federation enrollment flow (FED-M2-07).
|
||||
*
|
||||
* CreateEnrollmentTokenDto — admin generates a single-use enrollment token
|
||||
* RedeemEnrollmentTokenDto — remote peer submits CSR to redeem the token
|
||||
*/
|
||||
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export class CreateEnrollmentTokenDto {
|
||||
/** UUID of the federation grant this token will activate on redemption. */
|
||||
@IsUUID()
|
||||
grantId!: string;
|
||||
|
||||
/** UUID of the peer record that will receive the issued cert on redemption. */
|
||||
@IsUUID()
|
||||
peerId!: string;
|
||||
|
||||
/**
|
||||
* Token lifetime in seconds. Default 900 (15 min). Min 60. Max 900.
|
||||
* After this time the token is rejected even if unused.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(60)
|
||||
@Max(900)
|
||||
ttlSeconds: number = 900;
|
||||
}
|
||||
|
||||
export class RedeemEnrollmentTokenDto {
|
||||
/** PEM-encoded PKCS#10 Certificate Signing Request from the remote peer. */
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
csrPem!: string;
|
||||
}
|
||||
281
apps/gateway/src/federation/enrollment.service.ts
Normal file
281
apps/gateway/src/federation/enrollment.service.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* EnrollmentService — single-use enrollment token lifecycle (FED-M2-07).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Generate time-limited single-use enrollment tokens (admin action).
|
||||
* 2. Redeem a token: validate → atomically claim token → issue cert via
|
||||
* CaService → transactionally activate grant + update peer + write audit.
|
||||
*
|
||||
* Replay protection: the token is claimed (UPDATE WHERE used_at IS NULL) BEFORE
|
||||
* cert issuance. This prevents double cert minting on concurrent requests.
|
||||
* If cert issuance fails after claim, the token is consumed and the grant
|
||||
* stays pending — admin must create a new grant.
|
||||
*/
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
GoneException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
// X509Certificate is available as a named export in Node.js ≥ 15.6
|
||||
const { X509Certificate } = crypto;
|
||||
import {
|
||||
type Db,
|
||||
and,
|
||||
eq,
|
||||
isNull,
|
||||
sql,
|
||||
federationEnrollmentTokens,
|
||||
federationGrants,
|
||||
federationPeers,
|
||||
federationAuditLog,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { CaService } from './ca.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationScopeError } from './scope-schema.js';
|
||||
import type { CreateEnrollmentTokenDto } from './enrollment.dto.js';
|
||||
|
||||
export interface EnrollmentTokenResult {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface RedeemResult {
|
||||
certPem: string;
|
||||
certChainPem: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentService {
|
||||
private readonly logger = new Logger(EnrollmentService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly caService: CaService,
|
||||
private readonly grantsService: GrantsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate a single-use enrollment token for an admin to distribute
|
||||
* out-of-band to the remote peer operator.
|
||||
*/
|
||||
async createToken(dto: CreateEnrollmentTokenDto): Promise<EnrollmentTokenResult> {
|
||||
const ttl = Math.min(dto.ttlSeconds, 900);
|
||||
|
||||
// MED-3: Verify the grantId ↔ peerId binding — prevents attacker from
|
||||
// cross-wiring grants to attacker-controlled peers.
|
||||
const [grant] = await this.db
|
||||
.select({ peerId: federationGrants.peerId })
|
||||
.from(federationGrants)
|
||||
.where(eq(federationGrants.id, dto.grantId))
|
||||
.limit(1);
|
||||
if (!grant) {
|
||||
throw new NotFoundException(`Grant ${dto.grantId} not found`);
|
||||
}
|
||||
if (grant.peerId !== dto.peerId) {
|
||||
throw new BadRequestException(`peerId does not match the grant's registered peer`);
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
|
||||
await this.db.insert(federationEnrollmentTokens).values({
|
||||
token,
|
||||
grantId: dto.grantId,
|
||||
peerId: dto.peerId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Enrollment token created — grantId=${dto.grantId} peerId=${dto.peerId} expiresAt=${expiresAt.toISOString()}`,
|
||||
);
|
||||
|
||||
return { token, expiresAt: expiresAt.toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem an enrollment token.
|
||||
*
|
||||
* Full flow:
|
||||
* 1. Fetch token row — NotFoundException if not found
|
||||
* 2. usedAt set → GoneException (already used)
|
||||
* 3. expiresAt < now → GoneException (expired)
|
||||
* 4. Load grant — verify status is 'pending'
|
||||
* 5. Atomically claim token (UPDATE WHERE used_at IS NULL RETURNING token)
|
||||
* — if no rows returned, concurrent request won → GoneException
|
||||
* 6. Issue cert via CaService (network call, outside transaction)
|
||||
* — if this fails, token is consumed; grant stays pending; admin must recreate
|
||||
* 7. Transaction: activate grant + update peer record + write audit log
|
||||
* 8. Return { certPem, certChainPem }
|
||||
*/
|
||||
async redeem(token: string, csrPem: string): Promise<RedeemResult> {
|
||||
// HIGH-5: Track outcome so we can write a failure audit row on any error.
|
||||
let outcome: 'allowed' | 'denied' = 'denied';
|
||||
// row may be undefined if the token is not found — used defensively in catch.
|
||||
let row: typeof federationEnrollmentTokens.$inferSelect | undefined;
|
||||
|
||||
try {
|
||||
// 1. Fetch token row
|
||||
const [fetchedRow] = await this.db
|
||||
.select()
|
||||
.from(federationEnrollmentTokens)
|
||||
.where(eq(federationEnrollmentTokens.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (!fetchedRow) {
|
||||
throw new NotFoundException('Enrollment token not found');
|
||||
}
|
||||
row = fetchedRow;
|
||||
|
||||
// 2. Already used?
|
||||
if (row.usedAt !== null) {
|
||||
throw new GoneException('Enrollment token has already been used');
|
||||
}
|
||||
|
||||
// 3. Expired?
|
||||
if (row.expiresAt < new Date()) {
|
||||
throw new GoneException('Enrollment token has expired');
|
||||
}
|
||||
|
||||
// 4. Load grant and verify it is still pending
|
||||
let grant;
|
||||
try {
|
||||
grant = await this.grantsService.getGrant(row.grantId);
|
||||
} catch (err) {
|
||||
if (err instanceof FederationScopeError) {
|
||||
throw new BadRequestException(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (grant.status !== 'pending') {
|
||||
throw new GoneException(
|
||||
`Grant ${row.grantId} is no longer pending (status: ${grant.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Atomically claim the token BEFORE cert issuance to prevent double-minting.
|
||||
// WHERE used_at IS NULL ensures only one concurrent request wins.
|
||||
// Using .returning() works on both node-postgres and PGlite without rowCount inspection.
|
||||
const claimed = await this.db
|
||||
.update(federationEnrollmentTokens)
|
||||
.set({ usedAt: sql`NOW()` })
|
||||
.where(
|
||||
and(
|
||||
eq(federationEnrollmentTokens.token, token),
|
||||
isNull(federationEnrollmentTokens.usedAt),
|
||||
),
|
||||
)
|
||||
.returning({ token: federationEnrollmentTokens.token });
|
||||
|
||||
if (claimed.length === 0) {
|
||||
throw new GoneException('Enrollment token has already been used (concurrent request)');
|
||||
}
|
||||
|
||||
// 6. Issue certificate via CaService (network call — outside any transaction).
|
||||
// If this throws, the token is already consumed. The grant stays pending.
|
||||
// Admin must revoke the grant and create a new one.
|
||||
let issued;
|
||||
try {
|
||||
issued = await this.caService.issueCert({
|
||||
csrPem,
|
||||
grantId: row.grantId,
|
||||
subjectUserId: grant.subjectUserId,
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
} catch (err) {
|
||||
// HIGH-4: Log only the first 8 hex chars of the token for correlation — never log the full token.
|
||||
this.logger.error(
|
||||
`issueCert failed after token ${token.slice(0, 8)}... was claimed — grant ${row.grantId} is stranded pending`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
if (err instanceof FederationScopeError) {
|
||||
throw new BadRequestException((err as Error).message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 7. Atomically activate grant, update peer record, and write audit log.
|
||||
const certNotAfter = this.extractCertNotAfter(issued.certPem);
|
||||
await this.db.transaction(async (tx) => {
|
||||
// CRIT-2: Guard activation with WHERE status='pending' to prevent double-activation.
|
||||
const [activated] = await tx
|
||||
.update(federationGrants)
|
||||
.set({ status: 'active' })
|
||||
.where(and(eq(federationGrants.id, row!.grantId), eq(federationGrants.status, 'pending')))
|
||||
.returning({ id: federationGrants.id });
|
||||
if (!activated) {
|
||||
throw new ConflictException(
|
||||
`Grant ${row!.grantId} is no longer pending — cannot activate`,
|
||||
);
|
||||
}
|
||||
|
||||
// CRIT-2: Guard peer update with WHERE state='pending'.
|
||||
await tx
|
||||
.update(federationPeers)
|
||||
.set({
|
||||
certPem: issued.certPem,
|
||||
certSerial: issued.serialNumber,
|
||||
certNotAfter,
|
||||
state: 'active',
|
||||
})
|
||||
.where(and(eq(federationPeers.id, row!.peerId), eq(federationPeers.state, 'pending')));
|
||||
|
||||
await tx.insert(federationAuditLog).values({
|
||||
requestId: crypto.randomUUID(),
|
||||
peerId: row!.peerId,
|
||||
grantId: row!.grantId,
|
||||
verb: 'enrollment',
|
||||
resource: 'federation_grant',
|
||||
statusCode: 200,
|
||||
outcome: 'allowed',
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Enrollment complete — peerId=${row.peerId} grantId=${row.grantId} serial=${issued.serialNumber}`,
|
||||
);
|
||||
|
||||
outcome = 'allowed';
|
||||
|
||||
// 8. Return cert material
|
||||
return {
|
||||
certPem: issued.certPem,
|
||||
certChainPem: issued.certChainPem,
|
||||
};
|
||||
} catch (err) {
|
||||
// HIGH-5: Best-effort audit write on failure — do not let this throw.
|
||||
if (outcome === 'denied') {
|
||||
await this.db
|
||||
.insert(federationAuditLog)
|
||||
.values({
|
||||
requestId: crypto.randomUUID(),
|
||||
peerId: row?.peerId ?? null,
|
||||
grantId: row?.grantId ?? null,
|
||||
verb: 'enrollment',
|
||||
resource: 'federation_grant',
|
||||
statusCode:
|
||||
err instanceof GoneException ? 410 : err instanceof NotFoundException ? 404 : 500,
|
||||
outcome: 'denied',
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the notAfter date from a PEM certificate.
|
||||
* HIGH-2: No silent fallback — a cert that cannot be parsed should fail loud.
|
||||
*/
|
||||
private extractCertNotAfter(certPem: string): Date {
|
||||
const cert = new X509Certificate(certPem);
|
||||
return new Date(cert.validTo);
|
||||
}
|
||||
}
|
||||
39
apps/gateway/src/federation/federation-admin.dto.ts
Normal file
39
apps/gateway/src/federation/federation-admin.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* DTOs for the federation admin controller (FED-M2-08).
|
||||
*/
|
||||
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min } from 'class-validator';
|
||||
|
||||
export class CreatePeerKeypairDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
commonName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
displayName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
endpointUrl?: string;
|
||||
}
|
||||
|
||||
export class StorePeerCertDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
certPem!: string;
|
||||
}
|
||||
|
||||
export class GenerateEnrollmentTokenDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(60)
|
||||
@Max(900)
|
||||
ttlSeconds: number = 900;
|
||||
}
|
||||
|
||||
export class RevokeGrantBodyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
266
apps/gateway/src/federation/federation.controller.ts
Normal file
266
apps/gateway/src/federation/federation.controller.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* FederationController — admin REST API for federation management (FED-M2-08).
|
||||
*
|
||||
* Routes (all under /api/admin/federation, all require AdminGuard):
|
||||
*
|
||||
* Grant management:
|
||||
* POST /api/admin/federation/grants
|
||||
* GET /api/admin/federation/grants
|
||||
* GET /api/admin/federation/grants/:id
|
||||
* PATCH /api/admin/federation/grants/:id/revoke
|
||||
* POST /api/admin/federation/grants/:id/tokens
|
||||
*
|
||||
* Peer management:
|
||||
* GET /api/admin/federation/peers
|
||||
* POST /api/admin/federation/peers/keypair
|
||||
* PATCH /api/admin/federation/peers/:id/cert
|
||||
*
|
||||
* NOTE: The enrollment REDEMPTION endpoint (POST /api/federation/enrollment/:token)
|
||||
* is handled by EnrollmentController — not duplicated here.
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
|
||||
import { type Db, eq, federationPeers } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { sealClientKey } from './peer-key.util.js';
|
||||
import { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
||||
import {
|
||||
CreatePeerKeypairDto,
|
||||
GenerateEnrollmentTokenDto,
|
||||
RevokeGrantBodyDto,
|
||||
StorePeerCertDto,
|
||||
} from './federation-admin.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer to a Base64 string (for PEM encoding).
|
||||
*/
|
||||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (const b of bytes) {
|
||||
binary += String.fromCharCode(b);
|
||||
}
|
||||
return Buffer.from(binary, 'binary').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Base64 string in PEM armour.
|
||||
*/
|
||||
function toPem(label: string, b64: string): string {
|
||||
const lines = b64.match(/.{1,64}/g) ?? [];
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Controller('api/admin/federation')
|
||||
@UseGuards(AdminGuard)
|
||||
export class FederationController {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(GrantsService) private readonly grantsService: GrantsService,
|
||||
@Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService,
|
||||
) {}
|
||||
|
||||
// ─── Grant management ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/admin/federation/grants
|
||||
* Create a new grant in pending state.
|
||||
*/
|
||||
@Post('grants')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createGrant(@Body() body: CreateGrantDto) {
|
||||
return this.grantsService.createGrant(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/federation/grants
|
||||
* List grants with optional filters.
|
||||
*/
|
||||
@Get('grants')
|
||||
async listGrants(@Query() query: ListGrantsDto) {
|
||||
return this.grantsService.listGrants(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/federation/grants/:id
|
||||
* Get a single grant by ID.
|
||||
*/
|
||||
@Get('grants/:id')
|
||||
async getGrant(@Param('id') id: string) {
|
||||
return this.grantsService.getGrant(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/federation/grants/:id/revoke
|
||||
* Revoke an active grant.
|
||||
*/
|
||||
@Patch('grants/:id/revoke')
|
||||
async revokeGrant(@Param('id') id: string, @Body() body: RevokeGrantBodyDto) {
|
||||
return this.grantsService.revokeGrant(id, body.reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/federation/grants/:id/tokens
|
||||
* Generate a single-use enrollment token for a pending grant.
|
||||
* Returns the token plus an enrollmentUrl the operator shares out-of-band.
|
||||
*/
|
||||
@Post('grants/:id/tokens')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async generateToken(@Param('id') id: string, @Body() body: GenerateEnrollmentTokenDto) {
|
||||
const grant = await this.grantsService.getGrant(id);
|
||||
|
||||
const result = await this.enrollmentService.createToken({
|
||||
grantId: id,
|
||||
peerId: grant.peerId,
|
||||
ttlSeconds: body.ttlSeconds ?? 900,
|
||||
});
|
||||
|
||||
const baseUrl = process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242';
|
||||
const enrollmentUrl = `${baseUrl}/api/federation/enrollment/${result.token}`;
|
||||
|
||||
return {
|
||||
token: result.token,
|
||||
expiresAt: result.expiresAt,
|
||||
enrollmentUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Peer management ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/admin/federation/peers
|
||||
* List all federation peer rows.
|
||||
*/
|
||||
@Get('peers')
|
||||
async listPeers() {
|
||||
return this.db.select().from(federationPeers).orderBy(federationPeers.commonName);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/federation/peers/keypair
|
||||
* Generate a new peer entry with EC P-256 key pair and a PKCS#10 CSR.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Generate EC P-256 key pair via webcrypto
|
||||
* 2. Generate a self-signed CSR via @peculiar/x509
|
||||
* 3. Export private key as PEM
|
||||
* 4. sealClientKey(privatePem) → sealed blob
|
||||
* 5. Insert pending peer row
|
||||
* 6. Return { peerId, csrPem }
|
||||
*/
|
||||
@Post('peers/keypair')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createPeerKeypair(@Body() body: CreatePeerKeypairDto) {
|
||||
// 1. Generate EC P-256 key pair via Web Crypto
|
||||
const keyPair = await webcrypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true, // extractable
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
// 2. Generate PKCS#10 CSR
|
||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||
name: `CN=${body.commonName}`,
|
||||
keys: keyPair,
|
||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||
});
|
||||
|
||||
const csrPem = csr.toString('pem');
|
||||
|
||||
// 3. Export private key as PKCS#8 PEM
|
||||
const pkcs8Der = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
const privatePem = toPem('PRIVATE KEY', arrayBufferToBase64(pkcs8Der));
|
||||
|
||||
// 4. Seal the private key
|
||||
const sealed = sealClientKey(privatePem);
|
||||
|
||||
// 5. Insert pending peer row
|
||||
const [peer] = await this.db
|
||||
.insert(federationPeers)
|
||||
.values({
|
||||
commonName: body.commonName,
|
||||
displayName: body.displayName,
|
||||
certPem: '',
|
||||
certSerial: 'pending',
|
||||
certNotAfter: new Date(0),
|
||||
clientKeyPem: sealed,
|
||||
state: 'pending',
|
||||
endpointUrl: body.endpointUrl,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
peerId: peer!.id,
|
||||
csrPem,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/federation/peers/:id/cert
|
||||
* Store a signed certificate after enrollment completes.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Parse the cert to extract serial and notAfter
|
||||
* 2. Update the peer row with cert data + state='active'
|
||||
* 3. Return the updated peer row
|
||||
*/
|
||||
@Patch('peers/:id/cert')
|
||||
async storePeerCert(@Param('id') id: string, @Body() body: StorePeerCertDto) {
|
||||
// Ensure peer exists
|
||||
const [existing] = await this.db
|
||||
.select({ id: federationPeers.id })
|
||||
.from(federationPeers)
|
||||
.where(eq(federationPeers.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Peer ${id} not found`);
|
||||
}
|
||||
|
||||
// 1. Parse cert
|
||||
const x509 = new X509Certificate(body.certPem);
|
||||
const certSerial = x509.serialNumber;
|
||||
const certNotAfter = new Date(x509.validTo);
|
||||
|
||||
// 2. Update peer
|
||||
const [updated] = await this.db
|
||||
.update(federationPeers)
|
||||
.set({
|
||||
certPem: body.certPem,
|
||||
certSerial,
|
||||
certNotAfter,
|
||||
state: 'active',
|
||||
})
|
||||
.where(eq(federationPeers.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
48
apps/gateway/src/federation/federation.module.ts
Normal file
48
apps/gateway/src/federation/federation.module.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminGuard } from '../admin/admin.guard.js';
|
||||
import { CaService } from './ca.service.js';
|
||||
import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||
import { GetController } from './server/verbs/get.controller.js';
|
||||
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
EnrollmentController,
|
||||
FederationController,
|
||||
CapabilitiesController,
|
||||
ListController,
|
||||
GetController,
|
||||
],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
EnrollmentService,
|
||||
GrantsService,
|
||||
FederationClientService,
|
||||
QuerySourceService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
EnrollmentService,
|
||||
GrantsService,
|
||||
FederationClientService,
|
||||
QuerySourceService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
36
apps/gateway/src/federation/grants.dto.ts
Normal file
36
apps/gateway/src/federation/grants.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { IsDateString, IsIn, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateGrantDto {
|
||||
@IsUUID()
|
||||
peerId!: string;
|
||||
|
||||
@IsUUID()
|
||||
subjectUserId!: string;
|
||||
|
||||
@IsObject()
|
||||
scope!: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class ListGrantsDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
peerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
subjectUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['pending', 'active', 'revoked', 'expired'])
|
||||
status?: 'pending' | 'active' | 'revoked' | 'expired';
|
||||
}
|
||||
|
||||
export class RevokeGrantDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
190
apps/gateway/src/federation/grants.service.ts
Normal file
190
apps/gateway/src/federation/grants.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Federation grants service — CRUD + status transitions (FED-M2-06).
|
||||
*
|
||||
* Business logic only. CSR/cert work is handled by M2-07.
|
||||
*
|
||||
* Status lifecycle:
|
||||
* pending → active (activateGrant, called by M2-07 enrollment controller after cert signed)
|
||||
* active → revoked (revokeGrant)
|
||||
* active → expired (expireGrant, called by M6 scheduler)
|
||||
*/
|
||||
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { type Db, and, eq, federationGrants, federationPeers } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { parseFederationScope } from './scope-schema.js';
|
||||
import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js';
|
||||
|
||||
export type Grant = typeof federationGrants.$inferSelect;
|
||||
export type Peer = typeof federationPeers.$inferSelect;
|
||||
export type GrantWithPeer = Grant & { peer: Peer };
|
||||
|
||||
@Injectable()
|
||||
export class GrantsService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Create a new grant in `pending` state.
|
||||
* Validates the scope against the federation scope JSON schema before inserting.
|
||||
*/
|
||||
async createGrant(dto: CreateGrantDto): Promise<Grant> {
|
||||
// Throws FederationScopeError (a plain Error subclass) on invalid scope.
|
||||
parseFederationScope(dto.scope);
|
||||
|
||||
const [grant] = await this.db
|
||||
.insert(federationGrants)
|
||||
.values({
|
||||
peerId: dto.peerId,
|
||||
subjectUserId: dto.subjectUserId,
|
||||
scope: dto.scope,
|
||||
status: 'pending',
|
||||
expiresAt: dto.expiresAt != null ? new Date(dto.expiresAt) : null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return grant!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single grant by ID. Throws NotFoundException if not found.
|
||||
*/
|
||||
async getGrant(id: string): Promise<Grant> {
|
||||
const [grant] = await this.db
|
||||
.select()
|
||||
.from(federationGrants)
|
||||
.where(eq(federationGrants.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!grant) {
|
||||
throw new NotFoundException(`Grant ${id} not found`);
|
||||
}
|
||||
|
||||
return grant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single grant by ID, joined with its associated peer row.
|
||||
* Used by FederationAuthGuard to perform grant status + cert serial checks
|
||||
* in a single DB round-trip.
|
||||
*
|
||||
* Throws NotFoundException if the grant does not exist.
|
||||
* Throws NotFoundException if the associated peer row is missing (data integrity issue).
|
||||
*/
|
||||
async getGrantWithPeer(id: string): Promise<GrantWithPeer> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(federationGrants)
|
||||
.innerJoin(federationPeers, eq(federationGrants.peerId, federationPeers.id))
|
||||
.where(eq(federationGrants.id, id))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Grant ${id} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
...row.federation_grants,
|
||||
peer: row.federation_peers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List grants with optional filters for peerId, subjectUserId, and status.
|
||||
*/
|
||||
async listGrants(filters: ListGrantsDto): Promise<Grant[]> {
|
||||
const conditions = [];
|
||||
|
||||
if (filters.peerId != null) {
|
||||
conditions.push(eq(federationGrants.peerId, filters.peerId));
|
||||
}
|
||||
if (filters.subjectUserId != null) {
|
||||
conditions.push(eq(federationGrants.subjectUserId, filters.subjectUserId));
|
||||
}
|
||||
if (filters.status != null) {
|
||||
conditions.push(eq(federationGrants.status, filters.status));
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return this.db.select().from(federationGrants);
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(federationGrants)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a grant from `pending` → `active`.
|
||||
* Called by M2-07 enrollment controller after cert is signed.
|
||||
* Throws ConflictException if the grant is not in `pending` state.
|
||||
*/
|
||||
async activateGrant(id: string): Promise<Grant> {
|
||||
const grant = await this.getGrant(id);
|
||||
|
||||
if (grant.status !== 'pending') {
|
||||
throw new ConflictException(
|
||||
`Grant ${id} cannot be activated: expected status 'pending', got '${grant.status}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(federationGrants)
|
||||
.set({ status: 'active' })
|
||||
.where(eq(federationGrants.id, id))
|
||||
.returning();
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a grant from `active` → `revoked`.
|
||||
* Sets revokedAt and optionally revokedReason.
|
||||
* Throws ConflictException if the grant is not in `active` state.
|
||||
*/
|
||||
async revokeGrant(id: string, reason?: string): Promise<Grant> {
|
||||
const grant = await this.getGrant(id);
|
||||
|
||||
if (grant.status !== 'active') {
|
||||
throw new ConflictException(
|
||||
`Grant ${id} cannot be revoked: expected status 'active', got '${grant.status}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(federationGrants)
|
||||
.set({
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
revokedReason: reason ?? null,
|
||||
})
|
||||
.where(eq(federationGrants.id, id))
|
||||
.returning();
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a grant from `active` → `expired`.
|
||||
* Intended for use by the M6 scheduler.
|
||||
* Throws ConflictException if the grant is not in `active` state.
|
||||
*/
|
||||
async expireGrant(id: string): Promise<Grant> {
|
||||
const grant = await this.getGrant(id);
|
||||
|
||||
if (grant.status !== 'active') {
|
||||
throw new ConflictException(
|
||||
`Grant ${id} cannot be expired: expected status 'active', got '${grant.status}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(federationGrants)
|
||||
.set({ status: 'expired' })
|
||||
.where(eq(federationGrants.id, id))
|
||||
.returning();
|
||||
|
||||
return updated!;
|
||||
}
|
||||
}
|
||||
146
apps/gateway/src/federation/oid.util.ts
Normal file
146
apps/gateway/src/federation/oid.util.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Shared OID extraction helpers for Mosaic federation certificates.
|
||||
*
|
||||
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
|
||||
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
|
||||
* 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
|
||||
*
|
||||
* The encoding convention: each extension value is an OCTET STRING wrapping
|
||||
* an ASN.1 UTF8String TLV:
|
||||
* 0x0C (tag) + 1-byte length + UTF-8 bytes
|
||||
*
|
||||
* CaService encodes values this way via encodeUtf8String(), and this module
|
||||
* decodes them with the corresponding `.slice(2)` to skip tag + length byte.
|
||||
*
|
||||
* This module is intentionally pure — no NestJS, no DB, no network I/O.
|
||||
*/
|
||||
|
||||
import { X509Certificate } from '@peculiar/x509';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OID constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1';
|
||||
export const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MosaicOids {
|
||||
grantId: string;
|
||||
subjectUserId: string;
|
||||
}
|
||||
|
||||
export type OidExtractionResult =
|
||||
| { ok: true; value: MosaicOids }
|
||||
| {
|
||||
ok: false;
|
||||
error: 'MISSING_GRANT_ID' | 'MISSING_SUBJECT_USER_ID' | 'PARSE_ERROR';
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
/**
|
||||
* Decode an extension value encoded as ASN.1 UTF8String TLV
|
||||
* (tag 0x0C + 1-byte length + UTF-8 bytes).
|
||||
* Validates tag, length byte, and buffer bounds before decoding.
|
||||
* Throws a descriptive Error on malformed input; caller wraps in try/catch.
|
||||
*/
|
||||
function decodeUtf8StringTlv(value: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(value);
|
||||
|
||||
// Need at least tag + length bytes
|
||||
if (bytes.length < 2) {
|
||||
throw new Error(`UTF8String TLV too short: expected at least 2 bytes, got ${bytes.length}`);
|
||||
}
|
||||
|
||||
// Tag byte must be 0x0C (ASN.1 UTF8String)
|
||||
if (bytes[0] !== 0x0c) {
|
||||
throw new Error(
|
||||
`UTF8String TLV tag mismatch: expected 0x0C, got 0x${bytes[0]!.toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Only single-byte length form is supported (values 0–127); long form not needed
|
||||
// for OID strings of this length.
|
||||
const declaredLength = bytes[1]!;
|
||||
if (declaredLength > 127) {
|
||||
throw new Error(
|
||||
`UTF8String TLV uses long-form length (0x${declaredLength.toString(16).toUpperCase()}), which is not supported`,
|
||||
);
|
||||
}
|
||||
|
||||
// Declared length must match actual remaining bytes
|
||||
if (declaredLength !== bytes.length - 2) {
|
||||
throw new Error(
|
||||
`UTF8String TLV length mismatch: declared ${declaredLength}, actual ${bytes.length - 2}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Skip: tag (1 byte) + length (1 byte)
|
||||
return decoder.decode(bytes.slice(2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract Mosaic custom OIDs (grantId, subjectUserId) from an X.509 certificate
|
||||
* already parsed via @peculiar/x509.
|
||||
*
|
||||
* Returns `{ ok: true, value: MosaicOids }` on success, or
|
||||
* `{ ok: false, error: <code>, detail? }` on any failure — never throws.
|
||||
*/
|
||||
export function extractMosaicOids(cert: X509Certificate): OidExtractionResult {
|
||||
try {
|
||||
const grantIdExt = cert.getExtension(OID_MOSAIC_GRANT_ID);
|
||||
if (!grantIdExt) {
|
||||
return { ok: false, error: 'MISSING_GRANT_ID' };
|
||||
}
|
||||
|
||||
const subjectUserIdExt = cert.getExtension(OID_MOSAIC_SUBJECT_USER_ID);
|
||||
if (!subjectUserIdExt) {
|
||||
return { ok: false, error: 'MISSING_SUBJECT_USER_ID' };
|
||||
}
|
||||
|
||||
const grantId = decodeUtf8StringTlv(grantIdExt.value);
|
||||
const subjectUserId = decodeUtf8StringTlv(subjectUserIdExt.value);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: { grantId, subjectUserId },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'PARSE_ERROR',
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a PEM-encoded certificate and extract Mosaic OIDs.
|
||||
* Returns an OidExtractionResult — never throws.
|
||||
*/
|
||||
export function extractMosaicOidsFromPem(certPem: string): OidExtractionResult {
|
||||
let cert: X509Certificate;
|
||||
try {
|
||||
cert = new X509Certificate(certPem);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'PARSE_ERROR',
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
return extractMosaicOids(cert);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Unit tests for FederationAuthGuard (FED-M3-03).
|
||||
*
|
||||
* Coverage:
|
||||
* - Missing cert (no TLS socket / no getPeerCertificate) → 401
|
||||
* - Cert parse failure (corrupt DER raw bytes) → 401
|
||||
* - Missing grantId OID → 401
|
||||
* - Missing subjectUserId OID → 401
|
||||
* - Grant not found (GrantsService throws NotFoundException) → 403
|
||||
* - Grant in `pending` status → 403
|
||||
* - Grant in `revoked` status → 403
|
||||
* - Grant in `expired` status → 403
|
||||
* - Cert serial mismatch → 403
|
||||
* - Happy path: active grant + matching cert serial → context attached, returns true
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import { makeMosaicIssuedCert } from '../../__tests__/helpers/test-cert.js';
|
||||
import type { GrantsService, GrantWithPeer } from '../../grants.service.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GRANT_ID = 'a1111111-1111-1111-1111-111111111111';
|
||||
const USER_ID = 'b2222222-2222-2222-2222-222222222222';
|
||||
const PEER_ID = 'c3333333-3333-3333-3333-333333333333';
|
||||
|
||||
// Node.js TLS serialNumber is uppercase hex (no colons)
|
||||
const CERT_SERIAL_HEX = '01';
|
||||
|
||||
const VALID_SCOPE = { resources: ['tasks'], max_rows_per_query: 100 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a minimal GrantWithPeer-shaped mock.
|
||||
*/
|
||||
function makeGrantWithPeer(overrides: Partial<GrantWithPeer> = {}): GrantWithPeer {
|
||||
return {
|
||||
id: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: USER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
status: 'active',
|
||||
expiresAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
peer: {
|
||||
id: PEER_ID,
|
||||
commonName: 'test-peer',
|
||||
displayName: 'Test Peer',
|
||||
certPem: '',
|
||||
certSerial: CERT_SERIAL_HEX,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
clientKeyPem: null,
|
||||
state: 'active',
|
||||
endpointUrl: null,
|
||||
lastSeenAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock ExecutionContext with a pre-built TLS peer certificate.
|
||||
*
|
||||
* `certPem` — PEM string to present as the raw DER cert (converted to Buffer).
|
||||
* Pass null to simulate "no cert presented".
|
||||
* `certSerialHex` — serialNumber string returned by the TLS socket.
|
||||
* Node.js returns uppercase hex.
|
||||
* `hasTlsSocket` — if false, raw.socket has no getPeerCertificate (plain HTTP).
|
||||
*/
|
||||
function makeContext(opts: {
|
||||
certPem: string | null;
|
||||
certSerialHex?: string;
|
||||
hasTlsSocket?: boolean;
|
||||
}): {
|
||||
ctx: ExecutionContext;
|
||||
statusMock: ReturnType<typeof vi.fn>;
|
||||
sendMock: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const { certPem, certSerialHex = CERT_SERIAL_HEX, hasTlsSocket = true } = opts;
|
||||
|
||||
// Build peerCert object that Node.js TLS socket.getPeerCertificate() returns
|
||||
let peerCert: Record<string, unknown>;
|
||||
if (certPem === null) {
|
||||
// Simulate no cert: Node.js returns object with empty string fields
|
||||
peerCert = { raw: null, serialNumber: '' };
|
||||
} else {
|
||||
// Convert PEM to DER Buffer (strip headers + base64 decode)
|
||||
const b64 = certPem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\s+/g, '');
|
||||
const raw = Buffer.from(b64, 'base64');
|
||||
peerCert = { raw, serialNumber: certSerialHex };
|
||||
}
|
||||
|
||||
const getPeerCertificate = vi.fn().mockReturnValue(peerCert);
|
||||
|
||||
const socket = hasTlsSocket ? { getPeerCertificate } : {}; // No getPeerCertificate → non-TLS
|
||||
|
||||
// Fastify reply mocks
|
||||
const sendMock = vi.fn().mockReturnValue(undefined);
|
||||
const headerMock = vi.fn().mockReturnValue({ send: sendMock });
|
||||
const statusMock = vi.fn().mockReturnValue({ header: headerMock });
|
||||
|
||||
const request = {
|
||||
raw: {
|
||||
socket,
|
||||
},
|
||||
};
|
||||
|
||||
const reply = {
|
||||
status: statusMock,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => reply,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
return { ctx, statusMock, sendMock };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock GrantsService.
|
||||
*/
|
||||
function makeGrantsService(
|
||||
overrides: Partial<Pick<GrantsService, 'getGrantWithPeer'>> = {},
|
||||
): GrantsService {
|
||||
return {
|
||||
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer()),
|
||||
...overrides,
|
||||
} as unknown as GrantsService;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationAuthGuard', () => {
|
||||
let certPem: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Generate a real Mosaic-issued cert with the standard OIDs
|
||||
certPem = await makeMosaicIssuedCert({ grantId: GRANT_ID, subjectUserId: USER_ID });
|
||||
});
|
||||
|
||||
// ── 401: No TLS socket ────────────────────────────────────────────────────
|
||||
|
||||
it('returns 401 when there is no TLS socket (plain HTTP connection)', async () => {
|
||||
const { ctx, statusMock, sendMock } = makeContext({
|
||||
certPem: certPem,
|
||||
hasTlsSocket: false,
|
||||
});
|
||||
|
||||
const guard = new FederationAuthGuard(makeGrantsService());
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 401: Cert not presented ───────────────────────────────────────────────
|
||||
|
||||
it('returns 401 when the peer did not present a certificate', async () => {
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem: null });
|
||||
|
||||
const guard = new FederationAuthGuard(makeGrantsService());
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 401: Cert parse failure ───────────────────────────────────────────────
|
||||
|
||||
it('returns 401 when the certificate DER bytes are corrupt', async () => {
|
||||
// Build context with a cert that has garbage DER bytes
|
||||
const corruptPem = '-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----';
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem: corruptPem });
|
||||
|
||||
const guard = new FederationAuthGuard(makeGrantsService());
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 401: Missing grantId OID ─────────────────────────────────────────────
|
||||
|
||||
it('returns 401 when the cert is missing the grantId OID', async () => {
|
||||
// makeSelfSignedCert produces a cert without any Mosaic OIDs
|
||||
const { makeSelfSignedCert } = await import('../../__tests__/helpers/test-cert.js');
|
||||
const plainCert = await makeSelfSignedCert();
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem: plainCert });
|
||||
|
||||
const guard = new FederationAuthGuard(makeGrantsService());
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 401: Missing subjectUserId OID ───────────────────────────────────────
|
||||
|
||||
it('returns 401 when the cert has grantId OID but is missing subjectUserId OID', async () => {
|
||||
// Build a cert with only the grantId OID by importing cert generator internals
|
||||
const { webcrypto } = await import('node:crypto');
|
||||
const {
|
||||
X509CertificateGenerator,
|
||||
Extension,
|
||||
KeyUsagesExtension,
|
||||
KeyUsageFlags,
|
||||
BasicConstraintsExtension,
|
||||
cryptoProvider,
|
||||
} = await import('@peculiar/x509');
|
||||
|
||||
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
|
||||
|
||||
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
|
||||
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getTime() + 86_400_000);
|
||||
|
||||
// Encode grantId only — missing subjectUserId extension
|
||||
const utf8 = new TextEncoder().encode(GRANT_ID);
|
||||
const encoded = new Uint8Array(2 + utf8.length);
|
||||
encoded[0] = 0x0c;
|
||||
encoded[1] = utf8.length;
|
||||
encoded.set(utf8, 2);
|
||||
|
||||
const cert = await X509CertificateGenerator.createSelfSigned({
|
||||
serialNumber: '01',
|
||||
name: 'CN=partial-oid-test',
|
||||
notBefore: now,
|
||||
notAfter: tomorrow,
|
||||
signingAlgorithm: alg,
|
||||
keys,
|
||||
extensions: [
|
||||
new BasicConstraintsExtension(false),
|
||||
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
|
||||
new Extension('1.3.6.1.4.1.99999.1', false, encoded), // grantId only
|
||||
],
|
||||
});
|
||||
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem: cert.toString('pem') });
|
||||
|
||||
const guard = new FederationAuthGuard(makeGrantsService());
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(401);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Grant not found ─────────────────────────────────────────────────
|
||||
|
||||
it('returns 403 when the grantId from the cert does not exist in DB', async () => {
|
||||
const grantsService = makeGrantsService({
|
||||
getGrantWithPeer: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new NotFoundException(`Grant ${GRANT_ID} not found`)),
|
||||
});
|
||||
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Grant in `pending` status ───────────────────────────────────────
|
||||
|
||||
it('returns 403 when the grant is in pending status', async () => {
|
||||
const grantsService = makeGrantsService({
|
||||
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'pending' })),
|
||||
});
|
||||
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Grant in `revoked` status ───────────────────────────────────────
|
||||
|
||||
it('returns 403 when the grant is in revoked status', async () => {
|
||||
const grantsService = makeGrantsService({
|
||||
getGrantWithPeer: vi
|
||||
.fn()
|
||||
.mockResolvedValue(makeGrantWithPeer({ status: 'revoked', revokedAt: new Date() })),
|
||||
});
|
||||
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Grant in `expired` status ───────────────────────────────────────
|
||||
|
||||
it('returns 403 when the grant is in expired status', async () => {
|
||||
const grantsService = makeGrantsService({
|
||||
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'expired' })),
|
||||
});
|
||||
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: Cert serial mismatch ─────────────────────────────────────────────
|
||||
|
||||
it('returns 403 when the cert serial does not match the registered peer cert serial', async () => {
|
||||
// Return a grant whose peer has a different stored serial
|
||||
const grantsService = makeGrantsService({
|
||||
getGrantWithPeer: vi.fn().mockResolvedValue(
|
||||
makeGrantWithPeer({
|
||||
peer: {
|
||||
id: PEER_ID,
|
||||
commonName: 'test-peer',
|
||||
displayName: 'Test Peer',
|
||||
certPem: '',
|
||||
certSerial: 'DEADBEEF', // different from CERT_SERIAL_HEX='01'
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
clientKeyPem: null,
|
||||
state: 'active',
|
||||
endpointUrl: null,
|
||||
lastSeenAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Context presents cert with serial '01' but DB has 'DEADBEEF'
|
||||
const { ctx, statusMock, sendMock } = makeContext({ certPem, certSerialHex: '01' });
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 403: subjectUserId cert/DB mismatch (CRIT-1 regression test) ─────────
|
||||
|
||||
it('returns 403 when the cert subjectUserId does not match the DB grant subjectUserId', async () => {
|
||||
// Build a cert that claims an attacker's subjectUserId
|
||||
const attackerSubjectUserId = 'attacker-user-id';
|
||||
const attackerCertPem = await makeMosaicIssuedCert({
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: attackerSubjectUserId,
|
||||
});
|
||||
|
||||
// DB returns a grant with the legitimate USER_ID
|
||||
const grantsService = makeGrantsService({
|
||||
getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ subjectUserId: USER_ID })),
|
||||
});
|
||||
|
||||
// Cert presents attacker-user-id but DB has USER_ID — should be rejected
|
||||
const { ctx, statusMock, sendMock } = makeContext({
|
||||
certPem: attackerCertPem,
|
||||
certSerialHex: CERT_SERIAL_HEX,
|
||||
});
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────
|
||||
|
||||
it('returns true and attaches federationContext on happy path', async () => {
|
||||
const grant = makeGrantWithPeer({
|
||||
status: 'active',
|
||||
peer: {
|
||||
id: PEER_ID,
|
||||
commonName: 'test-peer',
|
||||
displayName: 'Test Peer',
|
||||
certPem: '',
|
||||
certSerial: CERT_SERIAL_HEX,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
clientKeyPem: null,
|
||||
state: 'active',
|
||||
endpointUrl: null,
|
||||
lastSeenAt: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
revokedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const grantsService = makeGrantsService({
|
||||
getGrantWithPeer: vi.fn().mockResolvedValue(grant),
|
||||
});
|
||||
|
||||
// Build context manually to capture what gets set on request.federationContext
|
||||
const b64 = certPem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\s+/g, '');
|
||||
const raw = Buffer.from(b64, 'base64');
|
||||
const peerCert = { raw, serialNumber: CERT_SERIAL_HEX };
|
||||
|
||||
const sendMock = vi.fn().mockReturnValue(undefined);
|
||||
const headerMock = vi.fn().mockReturnValue({ send: sendMock });
|
||||
const statusMock = vi.fn().mockReturnValue({ header: headerMock });
|
||||
|
||||
const request: Record<string, unknown> = {
|
||||
raw: {
|
||||
socket: { getPeerCertificate: vi.fn().mockReturnValue(peerCert) },
|
||||
},
|
||||
};
|
||||
|
||||
const reply = { status: statusMock };
|
||||
|
||||
const ctx = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => reply,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const guard = new FederationAuthGuard(grantsService);
|
||||
const result = await guard.canActivate(ctx);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(statusMock).not.toHaveBeenCalled();
|
||||
|
||||
// Verify the context was attached correctly
|
||||
expect(request['federationContext']).toEqual({
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: USER_ID,
|
||||
peerId: PEER_ID,
|
||||
scope: VALID_SCOPE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Unit tests for FederationScopeService (FED-M3-04).
|
||||
*
|
||||
* Coverage:
|
||||
* - resource allowlist deny
|
||||
* - excluded resource deny
|
||||
* - invalid scope deny
|
||||
* - invalid requested limit deny
|
||||
* - native RBAC deny as subjectUserId
|
||||
* - scope/native filter intersection for personal and team rows
|
||||
* - native RBAC personal deny wins over scope include_personal allow/default
|
||||
* - max_rows_per_query cap
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
|
||||
import type { FederationContext } from '../federation-context.js';
|
||||
|
||||
const GRANT_ID = 'grant-1';
|
||||
const PEER_ID = 'peer-1';
|
||||
const SUBJECT_USER_ID = 'user-1';
|
||||
|
||||
function makeContext(scope: Record<string, unknown>): FederationContext {
|
||||
return {
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
function makeNativeRbac(
|
||||
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
|
||||
): FederationNativeRbacEvaluator {
|
||||
return {
|
||||
evaluateReadAccess: vi.fn().mockResolvedValue(result),
|
||||
};
|
||||
}
|
||||
|
||||
describe('FederationScopeService', () => {
|
||||
let service: FederationScopeService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new FederationScopeService();
|
||||
});
|
||||
|
||||
it('allows a granted resource and returns a capped query filter', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
|
||||
max_rows_per_query: 50,
|
||||
}),
|
||||
resource: 'tasks',
|
||||
requestedLimit: 500,
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
filter: {
|
||||
resource: 'tasks',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 50,
|
||||
maxRowsPerQuery: 50,
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
resource: 'tasks',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
|
||||
resource: 'notes',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1', 'team-2'],
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('honors include_personal false even when native RBAC allows personal rows', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['memory'],
|
||||
filters: { memory: { include_personal: false } },
|
||||
max_rows_per_query: 25,
|
||||
}),
|
||||
resource: 'memory',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: false,
|
||||
teamIds: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
filters: { tasks: { include_personal: true } },
|
||||
max_rows_per_query: 25,
|
||||
}),
|
||||
resource: 'tasks',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: false, teamIds: ['team-1'] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: false,
|
||||
teamIds: ['team-1'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
|
||||
max_rows_per_query: 25,
|
||||
}),
|
||||
resource: 'tasks',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1'] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: true,
|
||||
filter: {
|
||||
includePersonal: false,
|
||||
teamIds: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('denies invalid grant scope before RBAC evaluation', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: [], max_rows_per_query: 100 }),
|
||||
resource: 'tasks',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'invalid_scope',
|
||||
stage: 'scope_parse',
|
||||
statusCode: 400,
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
resource: 'tasks',
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies unsupported resource names before RBAC evaluation', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'unknown_resource',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'invalid_resource',
|
||||
stage: 'resource_allowlist',
|
||||
statusCode: 403,
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 100,
|
||||
}),
|
||||
resource: 'credentials',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
resource: 'credentials',
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies supported resources that are not granted by scope', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'notes',
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_not_granted',
|
||||
stage: 'resource_allowlist',
|
||||
statusCode: 403,
|
||||
resource: 'notes',
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies invalid requested row limits before RBAC evaluation', async () => {
|
||||
const nativeRbac = makeNativeRbac({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'tasks',
|
||||
requestedLimit: 0,
|
||||
nativeRbac,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'invalid_limit',
|
||||
stage: 'row_cap',
|
||||
statusCode: 400,
|
||||
details: { requestedLimit: 0 },
|
||||
},
|
||||
});
|
||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
|
||||
const result = await service.evaluateAccess({
|
||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
||||
resource: 'tasks',
|
||||
nativeRbac: makeNativeRbac({
|
||||
allowed: false,
|
||||
reason: 'read:tasks denied',
|
||||
details: { permission: 'tasks:read' },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'native_rbac_denied',
|
||||
stage: 'native_rbac',
|
||||
statusCode: 403,
|
||||
message: 'read:tasks denied',
|
||||
grantId: GRANT_ID,
|
||||
peerId: PEER_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
resource: 'tasks',
|
||||
details: { permission: 'tasks:read' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
212
apps/gateway/src/federation/server/federation-auth.guard.ts
Normal file
212
apps/gateway/src/federation/server/federation-auth.guard.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* FederationAuthGuard — NestJS CanActivate guard for inbound federation requests.
|
||||
*
|
||||
* Validates the mTLS client certificate presented by a peer gateway, extracts
|
||||
* custom OIDs to identify the grant + subject user, loads the grant from DB,
|
||||
* asserts it is active, and verifies the cert serial against the registered peer
|
||||
* cert serial as a defense-in-depth measure.
|
||||
*
|
||||
* On success, attaches `request.federationContext` for downstream verb controllers.
|
||||
* On failure, responds with the federation wire-format error envelope (not raw
|
||||
* NestJS exception JSON) to match the federation protocol contract.
|
||||
*
|
||||
* ## Cert-serial check decision
|
||||
* The guard validates that the inbound client cert's serial number matches the
|
||||
* `certSerial` stored on the associated `federation_peers` row. This is a
|
||||
* defense-in-depth measure: even if the mTLS handshake is compromised at the
|
||||
* transport layer (e.g. misconfigured TLS terminator that forwards arbitrary
|
||||
* client certs), an attacker cannot replay a cert with a different serial than
|
||||
* what was registered during enrollment. This check is NOT loosened because:
|
||||
* 1. It is O(1) — no additional DB round-trip (peerId is on the grant row,
|
||||
* so we join to federationPeers in the same query).
|
||||
* 2. Cert renewal MUST update the stored serial — enforced by M6 scheduler.
|
||||
* 3. The OID-only path (without serial check) would allow any cert from the
|
||||
* same CA bearing the same grantId OID to succeed after cert compromise.
|
||||
*
|
||||
* ## FastifyRequest typing path
|
||||
* NestJS + Fastify wraps the raw Node.js IncomingMessage in a FastifyRequest.
|
||||
* The underlying TLS socket is accessed via `request.raw.socket`, which is a
|
||||
* `tls.TLSSocket` when the server is listening on HTTPS. In development/test
|
||||
* the gateway may run over plain HTTP, in which case `getPeerCertificate` is
|
||||
* not available. The guard safely handles both cases by checking for the
|
||||
* method's existence before calling it.
|
||||
*
|
||||
* Note: The guard reads the peer certificate from the *already-completed*
|
||||
* TLS handshake via `socket.getPeerCertificate(detailed=true)`. This relies
|
||||
* on the server being configured with `requestCert: true` at the TLS level
|
||||
* so Fastify/Node.js requests the client cert during the handshake.
|
||||
* The guard does NOT verify the cert chain itself — that is handled by the
|
||||
* TLS layer (Node.js `rejectUnauthorized: true` with the CA cert pinned).
|
||||
*/
|
||||
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import * as tls from 'node:tls';
|
||||
import { X509Certificate } from '@peculiar/x509';
|
||||
import { FederationForbiddenError, FederationUnauthorizedError } from '@mosaicstack/types';
|
||||
import { extractMosaicOids } from '../oid.util.js';
|
||||
import { GrantsService } from '../grants.service.js';
|
||||
import type { FederationContext } from './federation-context.js';
|
||||
import './federation-context.js'; // side-effect import: applies FastifyRequest module augmentation
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a federation wire-format error response directly on the Fastify reply.
|
||||
* Returns false — callers return this value from canActivate.
|
||||
*/
|
||||
function sendFederationError(
|
||||
reply: FastifyReply,
|
||||
error: FederationUnauthorizedError | FederationForbiddenError,
|
||||
): boolean {
|
||||
const statusCode = error.code === 'unauthorized' ? 401 : 403;
|
||||
void reply.status(statusCode).header('content-type', 'application/json').send(error.toEnvelope());
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable()
|
||||
export class FederationAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(FederationAuthGuard.name);
|
||||
|
||||
constructor(@Inject(GrantsService) private readonly grantsService: GrantsService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<FastifyRequest>();
|
||||
const reply = http.getResponse<FastifyReply>();
|
||||
|
||||
// ── Step 1: Extract peer certificate from TLS socket ────────────────────
|
||||
const rawSocket = request.raw.socket;
|
||||
|
||||
// Check TLS socket: getPeerCertificate is only available on TLS connections.
|
||||
if (
|
||||
!rawSocket ||
|
||||
typeof (rawSocket as Partial<tls.TLSSocket>).getPeerCertificate !== 'function'
|
||||
) {
|
||||
this.logger.warn('No TLS socket — client cert unavailable (non-mTLS connection)');
|
||||
return sendFederationError(
|
||||
reply,
|
||||
new FederationUnauthorizedError('Client certificate required'),
|
||||
);
|
||||
}
|
||||
|
||||
const tlsSocket = rawSocket as tls.TLSSocket;
|
||||
const peerCert = tlsSocket.getPeerCertificate(true);
|
||||
|
||||
// Node.js returns an object with empty string fields when no cert was presented.
|
||||
if (!peerCert || !peerCert.raw) {
|
||||
this.logger.warn('Peer certificate not presented (mTLS handshake did not supply cert)');
|
||||
return sendFederationError(
|
||||
reply,
|
||||
new FederationUnauthorizedError('Client certificate required'),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2: Parse the DER-encoded certificate via @peculiar/x509 ────────
|
||||
let cert: X509Certificate;
|
||||
try {
|
||||
// peerCert.raw is a Buffer containing the DER-encoded cert
|
||||
cert = new X509Certificate(peerCert.raw);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to parse peer certificate: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return sendFederationError(
|
||||
reply,
|
||||
new FederationUnauthorizedError('Client certificate could not be parsed'),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 3: Extract Mosaic custom OIDs ──────────────────────────────────
|
||||
const oidResult = extractMosaicOids(cert);
|
||||
|
||||
if (!oidResult.ok) {
|
||||
const message =
|
||||
oidResult.error === 'MISSING_GRANT_ID'
|
||||
? 'Client certificate is missing required OID: mosaic_grant_id (1.3.6.1.4.1.99999.1)'
|
||||
: oidResult.error === 'MISSING_SUBJECT_USER_ID'
|
||||
? 'Client certificate is missing required OID: mosaic_subject_user_id (1.3.6.1.4.1.99999.2)'
|
||||
: `Client certificate OID extraction failed: ${oidResult.detail ?? 'unknown error'}`;
|
||||
this.logger.warn(`OID extraction failure [${oidResult.error}]: ${message}`);
|
||||
return sendFederationError(reply, new FederationUnauthorizedError(message));
|
||||
}
|
||||
|
||||
const { grantId, subjectUserId } = oidResult.value;
|
||||
|
||||
// ── Step 4: Load grant from DB ───────────────────────────────────────────
|
||||
let grant: Awaited<ReturnType<GrantsService['getGrantWithPeer']>>;
|
||||
try {
|
||||
grant = await this.grantsService.getGrantWithPeer(grantId);
|
||||
} catch {
|
||||
// getGrantWithPeer throws NotFoundException when not found
|
||||
this.logger.warn(`Grant not found: ${grantId}`);
|
||||
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||
}
|
||||
|
||||
// ── Step 5: Assert grant is active ──────────────────────────────────────
|
||||
if (grant.status !== 'active') {
|
||||
this.logger.warn(`Grant ${grantId} is not active — status=${grant.status}`);
|
||||
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||
}
|
||||
|
||||
// ── Step 5b: Validate cert-extracted subjectUserId against DB (CRIT-1) ──
|
||||
// The cert claim is untrusted input; the DB row is authoritative.
|
||||
if (subjectUserId !== grant.subjectUserId) {
|
||||
this.logger.warn(`subjectUserId mismatch for grant ${grantId}`);
|
||||
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||
}
|
||||
|
||||
// ── Step 6: Defense-in-depth — cert serial must match registered peer ───
|
||||
// The serial number from Node.js TLS is upper-case hex without colons.
|
||||
// The @peculiar/x509 serialNumber is decimal. We compare using the native
|
||||
// Node.js crypto cert serial which is uppercase hex, matching DB storage.
|
||||
// Both are derived from the peerCert.serialNumber Node.js provides.
|
||||
const inboundSerial: string = peerCert.serialNumber ?? '';
|
||||
|
||||
if (!grant.peer.certSerial) {
|
||||
// Peer row exists but has no stored serial — something is wrong with enrollment
|
||||
this.logger.error(`Peer ${grant.peerId} has no stored certSerial — enrollment incomplete`);
|
||||
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||
}
|
||||
|
||||
// Normalize both to uppercase for comparison (Node.js serialNumber is
|
||||
// already uppercase hex; DB value was stored from extractSerial() which
|
||||
// returns crypto.X509Certificate.serialNumber — also uppercase hex).
|
||||
if (inboundSerial.toUpperCase() !== grant.peer.certSerial.toUpperCase()) {
|
||||
this.logger.warn(
|
||||
`Cert serial mismatch for grant ${grantId}: ` +
|
||||
`inbound=${inboundSerial} registered=${grant.peer.certSerial}`,
|
||||
);
|
||||
return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
|
||||
}
|
||||
|
||||
// ── Step 7: Attach FederationContext to request ──────────────────────────
|
||||
// Use grant.subjectUserId from DB (authoritative) — not the cert-extracted value.
|
||||
const federationContext: FederationContext = {
|
||||
grantId,
|
||||
subjectUserId: grant.subjectUserId,
|
||||
peerId: grant.peerId,
|
||||
scope: grant.scope as Record<string, unknown>,
|
||||
};
|
||||
|
||||
request.federationContext = federationContext;
|
||||
|
||||
this.logger.debug(
|
||||
`Federation auth OK — grantId=${grantId} peerId=${grant.peerId} subjectUserId=${grant.subjectUserId}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
39
apps/gateway/src/federation/server/federation-context.ts
Normal file
39
apps/gateway/src/federation/server/federation-context.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* FederationContext — attached to inbound federation requests after successful
|
||||
* mTLS + grant validation by FederationAuthGuard.
|
||||
*
|
||||
* Downstream verb controllers access this via `request.federationContext`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Augment FastifyRequest so TypeScript knows about the federation context
|
||||
* property that FederationAuthGuard attaches on success.
|
||||
*/
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
federationContext?: FederationContext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed context object attached to the request by FederationAuthGuard.
|
||||
* Carries all data extracted from the mTLS cert + grant DB row needed
|
||||
* by downstream federation verb handlers.
|
||||
*/
|
||||
export interface FederationContext {
|
||||
/** The federation grant ID extracted from OID 1.3.6.1.4.1.99999.1 */
|
||||
grantId: string;
|
||||
|
||||
/** The local subject user whose data is accessible under this grant */
|
||||
subjectUserId: string;
|
||||
|
||||
/** The peer gateway ID (from the grant's peerId FK) */
|
||||
peerId: string;
|
||||
|
||||
/**
|
||||
* Grant scope — determines which resources the peer may query.
|
||||
* Typed as Record<string, unknown> because the full scope schema lives in
|
||||
* scope-schema.ts; downstream handlers should narrow via parseFederationScope.
|
||||
*/
|
||||
scope: Record<string, unknown>;
|
||||
}
|
||||
31
apps/gateway/src/federation/server/index.ts
Normal file
31
apps/gateway/src/federation/server/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Federation server-side barrel — inbound request handling.
|
||||
*
|
||||
* Exports the mTLS auth guard and the FederationContext interface
|
||||
* for use by verb controllers (M3-05/06/07).
|
||||
*
|
||||
* Usage:
|
||||
* import { FederationAuthGuard } from './server/index.js';
|
||||
* @UseGuards(FederationAuthGuard)
|
||||
*/
|
||||
|
||||
export { FederationAuthGuard } from './federation-auth.guard.js';
|
||||
export { FederationScopeService } from './scope.service.js';
|
||||
export type { FederationContext } from './federation-context.js';
|
||||
export type {
|
||||
FederationNativeRbacAccess,
|
||||
FederationNativeRbacAllowedResult,
|
||||
FederationNativeRbacDeniedResult,
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeAllowedResult,
|
||||
FederationScopeDeniedResult,
|
||||
FederationScopeDenyCode,
|
||||
FederationScopeDenyDetails,
|
||||
FederationScopeDenyReason,
|
||||
FederationScopeDenyStage,
|
||||
FederationScopeEvaluationInput,
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from './scope.service.js';
|
||||
272
apps/gateway/src/federation/server/scope.service.ts
Normal file
272
apps/gateway/src/federation/server/scope.service.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* FederationScopeService — M3 server-side scope enforcement pipeline.
|
||||
*
|
||||
* Pure trust-boundary service: it validates the grant scope, asks an injected
|
||||
* native RBAC evaluator what the subject user can read locally, intersects that
|
||||
* answer with the federation scope filters, and returns a query filter for the
|
||||
* verb controllers. The service performs no DB calls directly.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
FEDERATION_RESOURCE_VALUES,
|
||||
type FederationResource,
|
||||
FederationScopeError,
|
||||
parseFederationScope,
|
||||
} from '../scope-schema.js';
|
||||
import type { FederationContext } from './federation-context.js';
|
||||
|
||||
const federationResourceSet: ReadonlySet<string> = new Set<string>(FEDERATION_RESOURCE_VALUES);
|
||||
|
||||
export type FederationScopeDenyStage =
|
||||
| 'scope_parse'
|
||||
| 'resource_allowlist'
|
||||
| 'resource_exclusion'
|
||||
| 'native_rbac'
|
||||
| 'row_cap';
|
||||
|
||||
export type FederationScopeDenyCode =
|
||||
| 'invalid_scope'
|
||||
| 'invalid_resource'
|
||||
| 'resource_not_granted'
|
||||
| 'resource_excluded'
|
||||
| 'native_rbac_denied'
|
||||
| 'invalid_limit';
|
||||
|
||||
export type FederationScopeDenyStatus = 400 | 403;
|
||||
|
||||
export interface FederationScopeDenyDetails {
|
||||
readonly [key: string]: string | number | boolean | readonly string[];
|
||||
}
|
||||
|
||||
export interface FederationScopeDenyReason {
|
||||
readonly code: FederationScopeDenyCode;
|
||||
readonly stage: FederationScopeDenyStage;
|
||||
readonly statusCode: FederationScopeDenyStatus;
|
||||
readonly message: string;
|
||||
readonly grantId: string;
|
||||
readonly peerId: string;
|
||||
readonly subjectUserId: string;
|
||||
readonly resource: string;
|
||||
readonly details?: FederationScopeDenyDetails;
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacRequest {
|
||||
readonly grantId: string;
|
||||
readonly peerId: string;
|
||||
readonly subjectUserId: string;
|
||||
readonly resource: FederationResource;
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacAccess {
|
||||
/** Whether this user may read personal rows for this resource. */
|
||||
readonly includePersonal: boolean;
|
||||
|
||||
/** Team IDs this user may read for this resource under native RBAC. */
|
||||
readonly teamIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacAllowedResult {
|
||||
readonly allowed: true;
|
||||
readonly access: FederationNativeRbacAccess;
|
||||
}
|
||||
|
||||
export interface FederationNativeRbacDeniedResult {
|
||||
readonly allowed: false;
|
||||
readonly reason?: string;
|
||||
readonly details?: FederationScopeDenyDetails;
|
||||
}
|
||||
|
||||
export type FederationNativeRbacResult =
|
||||
| FederationNativeRbacAllowedResult
|
||||
| FederationNativeRbacDeniedResult;
|
||||
|
||||
export interface FederationNativeRbacEvaluator {
|
||||
evaluateReadAccess(request: FederationNativeRbacRequest): Promise<FederationNativeRbacResult>;
|
||||
}
|
||||
|
||||
export interface FederationScopeEvaluationInput {
|
||||
readonly context: FederationContext;
|
||||
readonly resource: string;
|
||||
readonly requestedLimit?: number;
|
||||
readonly nativeRbac: FederationNativeRbacEvaluator;
|
||||
}
|
||||
|
||||
export interface FederationScopeQueryFilter {
|
||||
readonly resource: FederationResource;
|
||||
readonly subjectUserId: string;
|
||||
readonly includePersonal: boolean;
|
||||
readonly teamIds: readonly string[];
|
||||
readonly limit: number;
|
||||
readonly maxRowsPerQuery: number;
|
||||
}
|
||||
|
||||
export interface FederationScopeAllowedResult {
|
||||
readonly allowed: true;
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
}
|
||||
|
||||
export interface FederationScopeDeniedResult {
|
||||
readonly allowed: false;
|
||||
readonly deny: FederationScopeDenyReason;
|
||||
}
|
||||
|
||||
export type FederationScopeEvaluationResult =
|
||||
| FederationScopeAllowedResult
|
||||
| FederationScopeDeniedResult;
|
||||
|
||||
function isFederationResource(resource: string): resource is FederationResource {
|
||||
return federationResourceSet.has(resource);
|
||||
}
|
||||
|
||||
function uniqueStrings(values: readonly string[]): readonly string[] {
|
||||
return Array.from(new Set<string>(values));
|
||||
}
|
||||
|
||||
function intersectTeamIds(
|
||||
nativeTeamIds: readonly string[],
|
||||
scopedTeamIds: readonly string[] | undefined,
|
||||
): readonly string[] {
|
||||
const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds);
|
||||
|
||||
if (scopedTeamIds === undefined) {
|
||||
return uniqueNativeTeamIds;
|
||||
}
|
||||
|
||||
const nativeSet = new Set<string>(uniqueNativeTeamIds);
|
||||
return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId));
|
||||
}
|
||||
|
||||
function makeDenyReason(params: {
|
||||
readonly code: FederationScopeDenyCode;
|
||||
readonly stage: FederationScopeDenyStage;
|
||||
readonly statusCode?: FederationScopeDenyStatus;
|
||||
readonly message: string;
|
||||
readonly context: FederationContext;
|
||||
readonly resource: string;
|
||||
readonly details?: FederationScopeDenyDetails;
|
||||
}): FederationScopeDeniedResult {
|
||||
return {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: params.code,
|
||||
stage: params.stage,
|
||||
statusCode: params.statusCode ?? 403,
|
||||
message: params.message,
|
||||
grantId: params.context.grantId,
|
||||
peerId: params.context.peerId,
|
||||
subjectUserId: params.context.subjectUserId,
|
||||
resource: params.resource,
|
||||
...(params.details !== undefined ? { details: params.details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationScopeService {
|
||||
async evaluateAccess(
|
||||
input: FederationScopeEvaluationInput,
|
||||
): Promise<FederationScopeEvaluationResult> {
|
||||
const { context, resource, requestedLimit, nativeRbac } = input;
|
||||
|
||||
let scope: ReturnType<typeof parseFederationScope>;
|
||||
try {
|
||||
scope = parseFederationScope(context.scope);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof FederationScopeError
|
||||
? 'Federation grant scope is invalid'
|
||||
: 'Federation grant scope could not be parsed';
|
||||
const details = error instanceof Error ? { reason: error.message } : undefined;
|
||||
return makeDenyReason({
|
||||
code: 'invalid_scope',
|
||||
stage: 'scope_parse',
|
||||
statusCode: 400,
|
||||
message,
|
||||
context,
|
||||
resource,
|
||||
...(details !== undefined ? { details } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isFederationResource(resource)) {
|
||||
return makeDenyReason({
|
||||
code: 'invalid_resource',
|
||||
stage: 'resource_allowlist',
|
||||
message: 'Requested federation resource is not supported',
|
||||
context,
|
||||
resource,
|
||||
details: { supportedResources: FEDERATION_RESOURCE_VALUES },
|
||||
});
|
||||
}
|
||||
|
||||
if (scope.excluded_resources.includes(resource)) {
|
||||
return makeDenyReason({
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
context,
|
||||
resource,
|
||||
});
|
||||
}
|
||||
|
||||
if (!scope.resources.includes(resource)) {
|
||||
return makeDenyReason({
|
||||
code: 'resource_not_granted',
|
||||
stage: 'resource_allowlist',
|
||||
message: 'Requested federation resource is not granted by scope',
|
||||
context,
|
||||
resource,
|
||||
details: { grantedResources: scope.resources },
|
||||
});
|
||||
}
|
||||
|
||||
if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) {
|
||||
return makeDenyReason({
|
||||
code: 'invalid_limit',
|
||||
stage: 'row_cap',
|
||||
statusCode: 400,
|
||||
message: 'Requested row limit must be a positive integer',
|
||||
context,
|
||||
resource,
|
||||
details: { requestedLimit },
|
||||
});
|
||||
}
|
||||
|
||||
const nativeResult = await nativeRbac.evaluateReadAccess({
|
||||
grantId: context.grantId,
|
||||
peerId: context.peerId,
|
||||
subjectUserId: context.subjectUserId,
|
||||
resource,
|
||||
});
|
||||
|
||||
if (!nativeResult.allowed) {
|
||||
return makeDenyReason({
|
||||
code: 'native_rbac_denied',
|
||||
stage: 'native_rbac',
|
||||
message: nativeResult.reason ?? 'Subject user is not allowed to read this resource',
|
||||
context,
|
||||
resource,
|
||||
...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const scopeFilter = scope.filters?.[resource];
|
||||
const includePersonal =
|
||||
Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal;
|
||||
const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams);
|
||||
const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
filter: {
|
||||
resource,
|
||||
subjectUserId: context.subjectUserId,
|
||||
includePersonal,
|
||||
teamIds,
|
||||
limit,
|
||||
maxRowsPerQuery: scope.max_rows_per_query,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { FederationCapabilitiesResponseSchema, FEDERATION_VERBS } from '@mosaicstack/types';
|
||||
import { FederationScopeError } from '../../../scope-schema.js';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import { CapabilitiesController } from '../capabilities.controller.js';
|
||||
|
||||
const VALID_SCOPE = {
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 250,
|
||||
} as const;
|
||||
|
||||
const DEFAULTED_SCOPE = {
|
||||
resources: ['memory'],
|
||||
max_rows_per_query: 10,
|
||||
} as const;
|
||||
|
||||
function makeRequest(scope: Record<string, unknown>): FastifyRequest {
|
||||
return {
|
||||
federationContext: {
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
scope,
|
||||
},
|
||||
} as FastifyRequest;
|
||||
}
|
||||
|
||||
describe('CapabilitiesController', () => {
|
||||
it('declares GET /api/federation/v1/capabilities', () => {
|
||||
expect(Reflect.getMetadata('path', CapabilitiesController)).toBe(
|
||||
'api/federation/v1/capabilities',
|
||||
);
|
||||
expect(Reflect.getMetadata('path', CapabilitiesController.prototype.getCapabilities)).toBe('/');
|
||||
expect(Reflect.getMetadata('method', CapabilitiesController.prototype.getCapabilities)).toBe(
|
||||
RequestMethod.GET,
|
||||
);
|
||||
});
|
||||
|
||||
it('is protected only by FederationAuthGuard', () => {
|
||||
const guards = Reflect.getMetadata('__guards__', CapabilitiesController) as unknown[];
|
||||
|
||||
expect(guards).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('returns resources, excluded resources, max rows, and M3 supported verbs from the active grant scope', () => {
|
||||
const controller = new CapabilitiesController();
|
||||
|
||||
const response = controller.getCapabilities(makeRequest(VALID_SCOPE));
|
||||
|
||||
expect(response).toEqual({
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 250,
|
||||
supported_verbs: [...FEDERATION_VERBS],
|
||||
});
|
||||
expect(FederationCapabilitiesResponseSchema.safeParse(response).success).toBe(true);
|
||||
});
|
||||
|
||||
it('applies scope defaults without RBAC or resource filtering', () => {
|
||||
const controller = new CapabilitiesController();
|
||||
|
||||
const response = controller.getCapabilities(makeRequest(DEFAULTED_SCOPE));
|
||||
|
||||
expect(response).toEqual({
|
||||
resources: ['memory'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 10,
|
||||
supported_verbs: ['list', 'get', 'capabilities'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid scope state instead of returning an invalid capabilities contract', () => {
|
||||
const controller = new CapabilitiesController();
|
||||
|
||||
expect(() =>
|
||||
controller.getCapabilities(
|
||||
makeRequest({
|
||||
resources: [],
|
||||
max_rows_per_query: 0,
|
||||
}),
|
||||
),
|
||||
).toThrow(FederationScopeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createPgliteDb,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||
import { FederationGetQueryService } from '../get-query.service.js';
|
||||
|
||||
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'credentials',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
const SUBJECT_USER_ID = 'fed-m3-06-subject';
|
||||
const OTHER_USER_ID = 'fed-m3-06-other';
|
||||
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
|
||||
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
|
||||
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
|
||||
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
|
||||
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
|
||||
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
|
||||
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
|
||||
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
|
||||
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
|
||||
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
|
||||
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
|
||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
|
||||
|
||||
let dbHandle: DbHandle | undefined;
|
||||
|
||||
function makeService() {
|
||||
return new FederationGetQueryService({} as Db);
|
||||
}
|
||||
|
||||
function makeDbService() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
return new FederationGetQueryService(dbHandle.db);
|
||||
}
|
||||
|
||||
async function seedNotesFixture() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
|
||||
await dbHandle.db.insert(users).values([
|
||||
{
|
||||
id: SUBJECT_USER_ID,
|
||||
name: 'Federation Subject',
|
||||
email: `${SUBJECT_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
name: 'Federation Other',
|
||||
email: `${OTHER_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(teams).values([
|
||||
{
|
||||
id: TEAM_ID,
|
||||
name: 'FED-M3-06 Team',
|
||||
slug: 'fed-m3-06-team',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
managerId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_TEAM_ID,
|
||||
name: 'FED-M3-06 Unauthorized Team',
|
||||
slug: 'fed-m3-06-unauthorized-team',
|
||||
ownerId: OTHER_USER_ID,
|
||||
managerId: OTHER_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(projects).values([
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'FED-M3-06 Personal Project',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
ownerType: 'user',
|
||||
},
|
||||
{
|
||||
id: TEAM_PROJECT_ID,
|
||||
name: 'FED-M3-06 Team Project',
|
||||
teamId: TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_PROJECT_ID,
|
||||
name: 'FED-M3-06 Unauthorized Project',
|
||||
teamId: UNAUTHORIZED_TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missions).values([
|
||||
{
|
||||
id: PERSONAL_MISSION_ID,
|
||||
name: 'FED-M3-06 Personal Mission',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: TEAM_MISSION_ID,
|
||||
name: 'FED-M3-06 Team Mission',
|
||||
projectId: TEAM_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_MISSION_ID,
|
||||
name: 'FED-M3-06 Unauthorized Mission',
|
||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missionTasks).values([
|
||||
{
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on team mission',
|
||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
notes: 'other user note on team mission',
|
||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
missionId: PERSONAL_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on personal mission',
|
||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
missionId: UNAUTHORIZED_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note outside grant-visible missions',
|
||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe('FederationGetQueryService', () => {
|
||||
beforeAll(async () => {
|
||||
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
|
||||
await runPgliteMigrations(dbHandle);
|
||||
await seedNotesFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dbHandle?.close();
|
||||
dbHandle = undefined;
|
||||
});
|
||||
|
||||
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: 'credentials federation get access is not implemented in M3',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows personal memory reads without requiring team lookup', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'memory',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
|
||||
const service = makeService();
|
||||
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
|
||||
(
|
||||
service as unknown as {
|
||||
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
|
||||
}
|
||||
).listSubjectTeamIds = listSubjectTeamIds;
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'tasks',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
});
|
||||
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'credentials federation get is not implemented',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for unsupported resources instead of returning undefined', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
...CREDENTIAL_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
},
|
||||
id: 'row-1',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak another user mission task note through team-scoped get reads', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 'found',
|
||||
item: {
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
content: 'subject note on team mission',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject personal notes when includePersonal is false', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import type {
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../../scope.service.js';
|
||||
import { GetController } from '../get.controller.js';
|
||||
import type { FederationGetQueryResult } from '../get-query.service.js';
|
||||
|
||||
const FEDERATION_CONTEXT = {
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
||||
};
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
function makeRequest(): FastifyRequest {
|
||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function allowedScope(
|
||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
||||
): FederationScopeEvaluationResult {
|
||||
return { allowed: true, filter };
|
||||
}
|
||||
|
||||
function makeController(opts?: {
|
||||
scopeResult?: FederationScopeEvaluationResult;
|
||||
queryResult?: FederationGetQueryResult;
|
||||
}) {
|
||||
const scope = {
|
||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||
};
|
||||
const query = {
|
||||
evaluateReadAccess: vi.fn(),
|
||||
get: vi.fn().mockResolvedValue(
|
||||
opts?.queryResult ?? {
|
||||
status: 'found',
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
controller: new GetController(scope as never, query as never),
|
||||
scope,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
|
||||
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
|
||||
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
|
||||
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
const response = await controller.get('tasks', 'task-1', makeRequest());
|
||||
|
||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||
context: FEDERATION_CONTEXT,
|
||||
resource: 'tasks',
|
||||
requestedLimit: 1,
|
||||
nativeRbac: query,
|
||||
});
|
||||
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
|
||||
expect(response).toEqual({
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
_source: 'local',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(
|
||||
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: 'Federation context missing',
|
||||
},
|
||||
},
|
||||
status: 401,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||
const { controller, query } = makeController({
|
||||
scopeResult: {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
|
||||
const { controller } = makeController({ queryResult: { status: 'not_found' } });
|
||||
|
||||
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'not_found' } },
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
|
||||
const { controller } = makeController({
|
||||
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
|
||||
});
|
||||
|
||||
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Task is outside the federated scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the query layer denies an unsupported resource', async () => {
|
||||
const unsupportedFilter: FederationScopeQueryFilter = {
|
||||
...TASK_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
};
|
||||
const { controller } = makeController({
|
||||
scopeResult: allowedScope(unsupportedFilter),
|
||||
queryResult: {
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty ids before evaluating scope', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createPgliteDb,
|
||||
insights,
|
||||
missionTasks,
|
||||
missions,
|
||||
preferences,
|
||||
projects,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||
import { FederationListQueryService } from '../list-query.service.js';
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 2,
|
||||
maxRowsPerQuery: 2,
|
||||
};
|
||||
|
||||
const SUBJECT_USER_ID = 'fed-m3-05-subject';
|
||||
const OTHER_USER_ID = 'fed-m3-05-other';
|
||||
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
|
||||
const UNAUTHORIZED_TEAM_ID = '05000000-0000-4000-8000-000000000002';
|
||||
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
|
||||
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
|
||||
const UNAUTHORIZED_PROJECT_ID = '05000000-0000-4000-8000-000000000103';
|
||||
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
|
||||
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
|
||||
const UNAUTHORIZED_MISSION_ID = '05000000-0000-4000-8000-000000000203';
|
||||
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
|
||||
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
|
||||
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
|
||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '05000000-0000-4000-8000-000000000304';
|
||||
const INSIGHT_ONE_ID = '05000000-0000-4000-8000-000000000401';
|
||||
const INSIGHT_TWO_ID = '05000000-0000-4000-8000-000000000402';
|
||||
const PREFERENCE_ONE_ID = '05000000-0000-4000-8000-000000000501';
|
||||
const PREFERENCE_TWO_ID = '05000000-0000-4000-8000-000000000502';
|
||||
|
||||
let dbHandle: DbHandle | undefined;
|
||||
|
||||
function makeService() {
|
||||
return new FederationListQueryService({} as Db);
|
||||
}
|
||||
|
||||
function makeDbService() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
return new FederationListQueryService(dbHandle.db);
|
||||
}
|
||||
|
||||
async function seedNotesFixture() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
|
||||
await dbHandle.db.insert(users).values([
|
||||
{
|
||||
id: SUBJECT_USER_ID,
|
||||
name: 'Federation Subject',
|
||||
email: `${SUBJECT_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
name: 'Federation Other',
|
||||
email: `${OTHER_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(teams).values([
|
||||
{
|
||||
id: TEAM_ID,
|
||||
name: 'FED-M3-05 Team',
|
||||
slug: 'fed-m3-05-team',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
managerId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_TEAM_ID,
|
||||
name: 'FED-M3-05 Unauthorized Team',
|
||||
slug: 'fed-m3-05-unauthorized-team',
|
||||
ownerId: OTHER_USER_ID,
|
||||
managerId: OTHER_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(projects).values([
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'FED-M3-05 Personal Project',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
ownerType: 'user',
|
||||
},
|
||||
{
|
||||
id: TEAM_PROJECT_ID,
|
||||
name: 'FED-M3-05 Team Project',
|
||||
teamId: TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_PROJECT_ID,
|
||||
name: 'FED-M3-05 Unauthorized Project',
|
||||
teamId: UNAUTHORIZED_TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missions).values([
|
||||
{
|
||||
id: PERSONAL_MISSION_ID,
|
||||
name: 'FED-M3-05 Personal Mission',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: TEAM_MISSION_ID,
|
||||
name: 'FED-M3-05 Team Mission',
|
||||
projectId: TEAM_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_MISSION_ID,
|
||||
name: 'FED-M3-05 Unauthorized Mission',
|
||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missionTasks).values([
|
||||
{
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on team mission',
|
||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
notes: 'other user note on team mission',
|
||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
missionId: PERSONAL_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on personal mission',
|
||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
missionId: UNAUTHORIZED_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note outside grant-visible missions',
|
||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
const memoryCreatedAt = new Date('2026-06-24T05:00:00.000Z');
|
||||
await dbHandle.db.insert(insights).values([
|
||||
{
|
||||
id: INSIGHT_ONE_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
content: 'first insight',
|
||||
source: 'agent',
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
{
|
||||
id: INSIGHT_TWO_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
content: 'second insight',
|
||||
source: 'agent',
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(preferences).values([
|
||||
{
|
||||
id: PREFERENCE_ONE_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
key: 'fed-m3-05-pref-1',
|
||||
value: { enabled: true },
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
{
|
||||
id: PREFERENCE_TWO_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
key: 'fed-m3-05-pref-2',
|
||||
value: { enabled: false },
|
||||
createdAt: memoryCreatedAt,
|
||||
updatedAt: memoryCreatedAt,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function stubRows(
|
||||
service: FederationListQueryService,
|
||||
...pages: Array<Array<Record<string, unknown>>>
|
||||
) {
|
||||
const mock = vi.fn();
|
||||
for (const page of pages) {
|
||||
mock.mockResolvedValueOnce(page);
|
||||
}
|
||||
(
|
||||
service as unknown as {
|
||||
listAllRows: (
|
||||
_filter: FederationScopeQueryFilter,
|
||||
_rowLimit: number,
|
||||
_cursor: unknown,
|
||||
) => Promise<Array<Record<string, unknown>>>;
|
||||
}
|
||||
).listAllRows = mock;
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe('FederationListQueryService', () => {
|
||||
beforeAll(async () => {
|
||||
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
|
||||
await runPgliteMigrations(dbHandle);
|
||||
await seedNotesFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dbHandle?.close();
|
||||
dbHandle = undefined;
|
||||
});
|
||||
|
||||
it('denies sensitive resources in native RBAC for M3 list reads', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: 'credentials federation list access is not implemented in M3',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows personal memory reads without requiring team lookup', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'memory',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the scope row cap and returns an opaque next cursor when truncated', async () => {
|
||||
const service = makeService();
|
||||
const listAllRows = stubRows(
|
||||
service,
|
||||
[
|
||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
||||
{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') },
|
||||
],
|
||||
[{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
||||
);
|
||||
|
||||
const firstPage = await service.list({ filter: TASK_FILTER });
|
||||
|
||||
expect(firstPage).toEqual({
|
||||
items: [
|
||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
||||
],
|
||||
truncated: true,
|
||||
nextCursor: expect.any(String),
|
||||
});
|
||||
|
||||
expect(listAllRows).toHaveBeenNthCalledWith(1, TASK_FILTER, 3, undefined);
|
||||
|
||||
const secondPage = await service.list({ filter: TASK_FILTER, cursor: firstPage.nextCursor });
|
||||
expect(secondPage).toEqual({
|
||||
items: [{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
||||
truncated: false,
|
||||
});
|
||||
expect(listAllRows).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
TASK_FILTER,
|
||||
3,
|
||||
expect.objectContaining({ id: '2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid cursors instead of falling back to the first page', async () => {
|
||||
const service = makeService();
|
||||
stubRows(service, [{ id: '1' }]);
|
||||
|
||||
await expect(service.list({ filter: TASK_FILTER, cursor: 'not-base64-json' })).rejects.toThrow(
|
||||
'Invalid federation list cursor',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when a truncated page cannot encode a resumable cursor', async () => {
|
||||
const service = makeService();
|
||||
stubRows(service, [
|
||||
{ id: '2', createdAt: 'not-a-date' },
|
||||
{ id: '1', createdAt: 'not-a-date' },
|
||||
]);
|
||||
|
||||
await expect(service.list({ filter: { ...TASK_FILTER, limit: 1 } })).rejects.toThrow(
|
||||
'Federation list cursor cannot be encoded',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on unsupported resources instead of crashing pagination', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.list({
|
||||
filter: {
|
||||
...TASK_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('Unsupported federation list resource');
|
||||
});
|
||||
|
||||
it('does not leak another user mission task notes through team-scoped note reads', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
const result = await service.list({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const ids = result.items.map((item) => item['id']);
|
||||
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
|
||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
||||
});
|
||||
|
||||
it('does not return subject personal mission task notes when includePersonal is false', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
const result = await service.list({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
|
||||
});
|
||||
|
||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
const result = await service.list({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const ids = result.items.map((item) => item['id']);
|
||||
expect(ids).toContain(SUBJECT_PERSONAL_NOTE_ID);
|
||||
expect(ids).toContain(SUBJECT_TEAM_NOTE_ID);
|
||||
expect(ids).not.toContain(SUBJECT_UNAUTHORIZED_NOTE_ID);
|
||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
||||
});
|
||||
|
||||
it('paginates memory deterministically across insights and preferences', async () => {
|
||||
const service = makeDbService();
|
||||
const filter: FederationScopeQueryFilter = {
|
||||
resource: 'memory',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 2,
|
||||
maxRowsPerQuery: 2,
|
||||
};
|
||||
|
||||
const firstPage = await service.list({ filter });
|
||||
const secondPage = await service.list({ filter, cursor: firstPage.nextCursor });
|
||||
const firstPageIds = firstPage.items.map((item) => item['id']);
|
||||
const secondPageIds = secondPage.items.map((item) => item['id']);
|
||||
const allIds = [...firstPageIds, ...secondPageIds];
|
||||
|
||||
expect(firstPage).toMatchObject({ truncated: true, nextCursor: expect.any(String) });
|
||||
expect(firstPageIds).toEqual([INSIGHT_TWO_ID, INSIGHT_ONE_ID]);
|
||||
expect(secondPageIds).toEqual([PREFERENCE_TWO_ID, PREFERENCE_ONE_ID]);
|
||||
expect(new Set(allIds).size).toBe(allIds.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import type {
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../../scope.service.js';
|
||||
import { ListController } from '../list.controller.js';
|
||||
import type { FederationListQueryResult } from '../list-query.service.js';
|
||||
|
||||
const FEDERATION_CONTEXT = {
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
||||
};
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 10,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
function makeRequest(): FastifyRequest {
|
||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function allowedScope(
|
||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
||||
): FederationScopeEvaluationResult {
|
||||
return { allowed: true, filter };
|
||||
}
|
||||
|
||||
function makeController(opts?: {
|
||||
scopeResult?: FederationScopeEvaluationResult;
|
||||
queryResult?: FederationListQueryResult;
|
||||
}) {
|
||||
const scope = {
|
||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||
};
|
||||
const query = {
|
||||
evaluateReadAccess: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue(
|
||||
opts?.queryResult ?? {
|
||||
items: [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
controller: new ListController(scope as never, query as never),
|
||||
scope,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ListController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares POST /api/federation/v1/list/:resource protected only by FederationAuthGuard', () => {
|
||||
expect(Reflect.getMetadata('path', ListController)).toBe('api/federation/v1/list');
|
||||
expect(Reflect.getMetadata('path', ListController.prototype.list)).toBe(':resource');
|
||||
expect(Reflect.getMetadata('method', ListController.prototype.list)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__guards__', ListController)).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('runs AuthGuard context through ScopeService and returns local-source tagged rows', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
const response = await controller.list('tasks', makeRequest(), { limit: 10 });
|
||||
|
||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||
context: FEDERATION_CONTEXT,
|
||||
resource: 'tasks',
|
||||
requestedLimit: 10,
|
||||
nativeRbac: query,
|
||||
});
|
||||
expect(query.list).toHaveBeenCalledWith({ filter: TASK_FILTER, cursor: undefined });
|
||||
expect(response).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
_source: 'local',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves pagination metadata when row cap truncates the query layer result', async () => {
|
||||
const { controller } = makeController({
|
||||
queryResult: {
|
||||
items: [{ id: 'task-1' }],
|
||||
nextCursor: 'cursor-2',
|
||||
truncated: true,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await controller.list('tasks', makeRequest(), { cursor: 'cursor-1' });
|
||||
|
||||
expect(response).toEqual({
|
||||
items: [{ id: 'task-1', _source: 'local' }],
|
||||
nextCursor: 'cursor-2',
|
||||
_truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(
|
||||
controller.list('tasks', {} as unknown as FastifyRequest, {}),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: 'Federation context missing',
|
||||
},
|
||||
},
|
||||
status: 401,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||
const { controller, query } = makeController({
|
||||
scopeResult: {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.list('credentials', makeRequest(), {})).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
expect(query.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed request body fields before querying storage', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(controller.list('tasks', makeRequest(), { cursor: 123 })).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
await expect(controller.list('tasks', makeRequest(), { limit: false })).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
await expect(controller.list('tasks', makeRequest(), { limit: 'abc' })).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Federation capabilities verb (FED-M3-07).
|
||||
*
|
||||
* Returns the read-only capability envelope for the active grant attached by
|
||||
* FederationAuthGuard. This endpoint intentionally does not invoke native RBAC
|
||||
* or ScopeService: an active grant is enough to ask what the grant allows.
|
||||
*/
|
||||
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FEDERATION_VERBS,
|
||||
type FederationCapabilitiesResponse,
|
||||
type FederationVerb,
|
||||
} from '@mosaicstack/types';
|
||||
import { parseFederationScope } from '../../scope-schema.js';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
|
||||
@Controller('api/federation/v1/capabilities')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class CapabilitiesController {
|
||||
@Get()
|
||||
getCapabilities(@Req() request: FastifyRequest): FederationCapabilitiesResponse {
|
||||
if (!request.federationContext) {
|
||||
throw new Error('Federation context missing after auth guard');
|
||||
}
|
||||
|
||||
const scope = parseFederationScope(request.federationContext.scope);
|
||||
|
||||
return {
|
||||
resources: [...scope.resources],
|
||||
excluded_resources: [...scope.excluded_resources],
|
||||
max_rows_per_query: scope.max_rows_per_query,
|
||||
supported_verbs: [...FEDERATION_VERBS] satisfies FederationVerb[],
|
||||
};
|
||||
}
|
||||
}
|
||||
311
apps/gateway/src/federation/server/verbs/get-query.service.ts
Normal file
311
apps/gateway/src/federation/server/verbs/get-query.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Federation get query layer (FED-M3-06).
|
||||
*
|
||||
* Read-only DB adapter used by GetController after FederationAuthGuard and
|
||||
* FederationScopeService have established the subject user, allowed resource,
|
||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
||||
* deferred to M4.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
insights,
|
||||
or,
|
||||
missionTasks,
|
||||
missions,
|
||||
preferences,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../../../database/database.module.js';
|
||||
import type {
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../scope.service.js';
|
||||
|
||||
export interface FederationGetQueryRequest {
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
|
||||
readonly status: 'found';
|
||||
readonly item: T;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryNotFoundResult {
|
||||
readonly status: 'not_found';
|
||||
}
|
||||
|
||||
export interface FederationGetQueryDeniedResult {
|
||||
readonly status: 'denied';
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
|
||||
| FederationGetQueryFoundResult<T>
|
||||
| FederationGetQueryNotFoundResult
|
||||
| FederationGetQueryDeniedResult;
|
||||
|
||||
type RowObject = Record<string, unknown>;
|
||||
|
||||
function firstRow<T>(rows: T[]): T | undefined {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function rowBelongsToAccessibleProjectOrMission(
|
||||
row: { projectId?: string | null; missionId?: string | null },
|
||||
projectIds: readonly string[],
|
||||
missionIds: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
|
||||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async evaluateReadAccess(
|
||||
request: FederationNativeRbacRequest,
|
||||
): Promise<FederationNativeRbacResult> {
|
||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `${request.resource} federation get access is not implemented in M3`,
|
||||
details: { resource: request.resource },
|
||||
};
|
||||
}
|
||||
|
||||
if (request.resource === 'memory') {
|
||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
||||
}
|
||||
|
||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
||||
}
|
||||
|
||||
async get<T extends RowObject = RowObject>(
|
||||
request: FederationGetQueryRequest,
|
||||
): Promise<FederationGetQueryResult<T>> {
|
||||
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
||||
}
|
||||
|
||||
private async getByResource(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
switch (filter.resource) {
|
||||
case 'tasks':
|
||||
return this.getTask(filter, id);
|
||||
case 'notes':
|
||||
return this.getNote(filter, id);
|
||||
case 'memory':
|
||||
return this.getMemory(filter, id);
|
||||
case 'credentials':
|
||||
case 'api_keys':
|
||||
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
|
||||
default:
|
||||
return {
|
||||
status: 'denied',
|
||||
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, subjectUserId));
|
||||
|
||||
return rows.map((row) => row.teamId);
|
||||
}
|
||||
|
||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
||||
const clauses = [];
|
||||
if (filter.includePersonal) {
|
||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
||||
}
|
||||
if (filter.teamIds.length > 0) {
|
||||
// Project team ownership follows TeamsService.canAccessProject: team-owned
|
||||
// rows are authorized through projects.teamId, while ownerId remains the
|
||||
// user who created/bootstrapped the project.
|
||||
clauses.push(
|
||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
||||
);
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: missions.id })
|
||||
.from(missions)
|
||||
.where(inArray(missions.projectId, [...projectIds]));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async getTask(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
description: tasks.description,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
projectId: tasks.projectId,
|
||||
missionId: tasks.missionId,
|
||||
assignee: tasks.assignee,
|
||||
tags: tasks.tags,
|
||||
dueDate: tasks.dueDate,
|
||||
metadata: tasks.metadata,
|
||||
createdAt: tasks.createdAt,
|
||||
updatedAt: tasks.updatedAt,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
|
||||
return { status: 'denied', reason: 'Task is outside the federated scope' };
|
||||
}
|
||||
|
||||
return { status: 'found', item: row as RowObject };
|
||||
}
|
||||
|
||||
private async getNote(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: missionTasks.id,
|
||||
missionId: missionTasks.missionId,
|
||||
taskId: missionTasks.taskId,
|
||||
userId: missionTasks.userId,
|
||||
status: missionTasks.status,
|
||||
content: missionTasks.notes,
|
||||
createdAt: missionTasks.createdAt,
|
||||
updatedAt: missionTasks.updatedAt,
|
||||
})
|
||||
.from(missionTasks)
|
||||
.where(eq(missionTasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row || row.content === null || row.content === '') {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
|
||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||
// Scope-visible missions must intersect with subject ownership; team scope
|
||||
// narrows mission IDs but never widens note reads to another user's rows.
|
||||
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
|
||||
return { status: 'denied', reason: 'Note is outside the federated scope' };
|
||||
}
|
||||
|
||||
const item = { ...row } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
|
||||
private async getMemory(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const [insightRow, preferenceRow] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: insights.id,
|
||||
userId: insights.userId,
|
||||
kind: insights.source,
|
||||
content: insights.content,
|
||||
category: insights.category,
|
||||
relevanceScore: insights.relevanceScore,
|
||||
metadata: insights.metadata,
|
||||
createdAt: insights.createdAt,
|
||||
updatedAt: insights.updatedAt,
|
||||
})
|
||||
.from(insights)
|
||||
.where(eq(insights.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
this.db
|
||||
.select({
|
||||
id: preferences.id,
|
||||
userId: preferences.userId,
|
||||
kind: preferences.category,
|
||||
key: preferences.key,
|
||||
value: preferences.value,
|
||||
source: preferences.source,
|
||||
mutable: preferences.mutable,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
})
|
||||
.from(preferences)
|
||||
.where(eq(preferences.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
]);
|
||||
|
||||
const candidates = [insightRow, preferenceRow].filter(
|
||||
(row): row is NonNullable<typeof row> => row !== undefined,
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
if (!filter.includePersonal) {
|
||||
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
|
||||
}
|
||||
|
||||
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
|
||||
if (!accessible) {
|
||||
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
|
||||
}
|
||||
|
||||
const item = { ...accessible } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
}
|
||||
100
apps/gateway/src/federation/server/verbs/get.controller.ts
Normal file
100
apps/gateway/src/federation/server/verbs/get.controller.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Federation get verb (FED-M3-06).
|
||||
*
|
||||
* POST /api/federation/v1/get/:resource/:id
|
||||
*
|
||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||
* the read-only query layer fetches one local row and tags it with `_source`.
|
||||
* Read audit-log writes are deferred to M4; this controller does not persist
|
||||
* request or response bodies.
|
||||
*/
|
||||
|
||||
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FederationInvalidRequestError,
|
||||
FederationNotFoundError,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
SOURCE_LOCAL,
|
||||
type FederationGetResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
import { FederationScopeService } from '../scope.service.js';
|
||||
import { FederationGetQueryService } from './get-query.service.js';
|
||||
|
||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||
|
||||
function scopeDenyToHttpException(deny: {
|
||||
readonly statusCode: 400 | 403;
|
||||
readonly message: string;
|
||||
}): HttpException {
|
||||
const ErrorClass =
|
||||
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
|
||||
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
|
||||
}
|
||||
|
||||
@Controller('api/federation/v1/get')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class GetController {
|
||||
constructor(
|
||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
|
||||
) {}
|
||||
|
||||
@Post(':resource/:id')
|
||||
async get(
|
||||
@Param('resource') resource: string,
|
||||
@Param('id') id: string,
|
||||
@Req() request: FastifyRequest,
|
||||
): Promise<FederationGetResponse<FederatedRow>> {
|
||||
if (!request.federationContext) {
|
||||
throw new HttpException(
|
||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (id.trim().length === 0) {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const scopeResult = await this.scope.evaluateAccess({
|
||||
context: request.federationContext,
|
||||
resource,
|
||||
requestedLimit: 1,
|
||||
nativeRbac: this.query,
|
||||
});
|
||||
|
||||
if (!scopeResult.allowed) {
|
||||
throw scopeDenyToHttpException(scopeResult.deny);
|
||||
}
|
||||
|
||||
const result = await this.query.get({ filter: scopeResult.filter, id });
|
||||
if (result.status === 'not_found') {
|
||||
throw new HttpException(
|
||||
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
|
||||
404,
|
||||
);
|
||||
}
|
||||
if (result.status === 'denied') {
|
||||
throw new HttpException(
|
||||
new FederationScopeViolationError(result.reason, {
|
||||
resource,
|
||||
id,
|
||||
grantId: request.federationContext.grantId,
|
||||
peerId: request.federationContext.peerId,
|
||||
subjectUserId: request.federationContext.subjectUserId,
|
||||
}).toEnvelope(),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return { item: { ...result.item, _source: SOURCE_LOCAL } };
|
||||
}
|
||||
}
|
||||
408
apps/gateway/src/federation/server/verbs/list-query.service.ts
Normal file
408
apps/gateway/src/federation/server/verbs/list-query.service.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Federation list query layer (FED-M3-05).
|
||||
*
|
||||
* Read-only DB adapter used by ListController after FederationAuthGuard and
|
||||
* FederationScopeService have established the subject user, allowed resource,
|
||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
||||
* deferred to M4.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
insights,
|
||||
isNotNull,
|
||||
lt,
|
||||
missionTasks,
|
||||
missions,
|
||||
or,
|
||||
preferences,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import type {
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../scope.service.js';
|
||||
import { DB } from '../../../database/database.module.js';
|
||||
|
||||
export interface FederationListQueryRequest {
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
readonly cursor?: string;
|
||||
}
|
||||
|
||||
export interface FederationListQueryResult<T extends object = Record<string, unknown>> {
|
||||
readonly items: T[];
|
||||
readonly nextCursor?: string;
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
type CursorSource = 'insights' | 'preferences';
|
||||
const CURSOR_SOURCE = Symbol('federationCursorSource');
|
||||
|
||||
type RowObject = Record<string, unknown> & { readonly [CURSOR_SOURCE]?: CursorSource };
|
||||
|
||||
interface KeysetCursor {
|
||||
readonly createdAt: Date;
|
||||
readonly id: string;
|
||||
readonly source?: CursorSource;
|
||||
}
|
||||
|
||||
function encodeCursor(row: RowObject): string {
|
||||
const createdAt = row['createdAt'];
|
||||
const id = row['id'];
|
||||
if (!(createdAt instanceof Date) || typeof id !== 'string') {
|
||||
throw new Error('Federation list cursor cannot be encoded');
|
||||
}
|
||||
|
||||
const source = row[CURSOR_SOURCE];
|
||||
return Buffer.from(
|
||||
JSON.stringify({ createdAt: createdAt.toISOString(), id, ...(source ? { source } : {}) }),
|
||||
'utf8',
|
||||
).toString('base64url');
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
|
||||
if (cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('cursor must be an object');
|
||||
}
|
||||
|
||||
const { createdAt, id, source } = parsed as {
|
||||
createdAt?: unknown;
|
||||
id?: unknown;
|
||||
source?: unknown;
|
||||
};
|
||||
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
|
||||
throw new Error('cursor is missing createdAt or id');
|
||||
}
|
||||
if (source !== undefined && source !== 'insights' && source !== 'preferences') {
|
||||
throw new Error('cursor source is invalid');
|
||||
}
|
||||
|
||||
const date = new Date(createdAt);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error('cursor createdAt is invalid');
|
||||
}
|
||||
|
||||
return { createdAt: date, id, ...(source ? { source } : {}) };
|
||||
} catch {
|
||||
throw new Error('Invalid federation list cursor');
|
||||
}
|
||||
}
|
||||
|
||||
function paginate<T extends RowObject>(rows: T[], limit: number): FederationListQueryResult<T> {
|
||||
const page = rows.slice(0, limit);
|
||||
const hasMore = rows.length > limit;
|
||||
const nextCursor = hasMore ? encodeCursor(page[page.length - 1] ?? {}) : undefined;
|
||||
|
||||
return {
|
||||
items: page,
|
||||
truncated: hasMore,
|
||||
...(nextCursor !== undefined ? { nextCursor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function markCursorSource<T extends RowObject>(row: T, source: CursorSource): T {
|
||||
Object.defineProperty(row, CURSOR_SOURCE, {
|
||||
value: source,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function sortRows(rows: RowObject[]): RowObject[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
|
||||
const bTime = b['createdAt'] instanceof Date ? b['createdAt'].getTime() : 0;
|
||||
if (aTime !== bTime) {
|
||||
return bTime - aTime;
|
||||
}
|
||||
return String(b['id'] ?? '').localeCompare(String(a['id'] ?? ''));
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationListQueryService implements FederationNativeRbacEvaluator {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async evaluateReadAccess(
|
||||
request: FederationNativeRbacRequest,
|
||||
): Promise<FederationNativeRbacResult> {
|
||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `${request.resource} federation list access is not implemented in M3`,
|
||||
details: { resource: request.resource },
|
||||
};
|
||||
}
|
||||
|
||||
if (request.resource === 'memory') {
|
||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
||||
}
|
||||
|
||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
||||
}
|
||||
|
||||
async list<T extends RowObject = RowObject>(
|
||||
request: FederationListQueryRequest,
|
||||
): Promise<FederationListQueryResult<T>> {
|
||||
const cursor = decodeCursor(request.cursor);
|
||||
const rows = await this.listAllRows(request.filter, request.filter.limit + 1, cursor);
|
||||
return paginate(rows as T[], request.filter.limit);
|
||||
}
|
||||
|
||||
private async listAllRows(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
switch (filter.resource) {
|
||||
case 'tasks':
|
||||
return this.listTasks(filter, rowLimit, cursor);
|
||||
case 'notes':
|
||||
return this.listNotes(filter, rowLimit, cursor);
|
||||
case 'memory':
|
||||
return this.listMemory(filter, rowLimit, cursor);
|
||||
case 'credentials':
|
||||
case 'api_keys':
|
||||
return [];
|
||||
default:
|
||||
throw new Error(`Unsupported federation list resource: ${String(filter.resource)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, subjectUserId));
|
||||
|
||||
return rows.map((row) => row.teamId);
|
||||
}
|
||||
|
||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
||||
const clauses = [];
|
||||
if (filter.includePersonal) {
|
||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
||||
}
|
||||
if (filter.teamIds.length > 0) {
|
||||
clauses.push(
|
||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
||||
);
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: missions.id })
|
||||
.from(missions)
|
||||
.where(inArray(missions.projectId, [...projectIds]));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async listTasks(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
const clauses = [];
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
clauses.push(inArray(tasks.projectId, projectIds));
|
||||
}
|
||||
if (missionIds.length > 0) {
|
||||
clauses.push(inArray(tasks.missionId, missionIds));
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
|
||||
const cursorClause = cursor
|
||||
? or(
|
||||
lt(tasks.createdAt, cursor.createdAt),
|
||||
and(eq(tasks.createdAt, cursor.createdAt), lt(tasks.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
description: tasks.description,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
projectId: tasks.projectId,
|
||||
missionId: tasks.missionId,
|
||||
assignee: tasks.assignee,
|
||||
tags: tasks.tags,
|
||||
dueDate: tasks.dueDate,
|
||||
metadata: tasks.metadata,
|
||||
createdAt: tasks.createdAt,
|
||||
updatedAt: tasks.updatedAt,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(and(scopeClause, cursorClause))
|
||||
.orderBy(desc(tasks.createdAt), desc(tasks.id))
|
||||
.limit(rowLimit);
|
||||
|
||||
return sortRows(rows as RowObject[]);
|
||||
}
|
||||
|
||||
private async listNotes(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
|
||||
if (missionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||
// Team visibility can narrow the mission set, but it must never widen the
|
||||
// query to other users' mission task notes.
|
||||
const scopeClause = and(
|
||||
eq(missionTasks.userId, filter.subjectUserId),
|
||||
inArray(missionTasks.missionId, missionIds),
|
||||
);
|
||||
const cursorClause = cursor
|
||||
? or(
|
||||
lt(missionTasks.createdAt, cursor.createdAt),
|
||||
and(eq(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: missionTasks.id,
|
||||
missionId: missionTasks.missionId,
|
||||
taskId: missionTasks.taskId,
|
||||
status: missionTasks.status,
|
||||
content: missionTasks.notes,
|
||||
createdAt: missionTasks.createdAt,
|
||||
updatedAt: missionTasks.updatedAt,
|
||||
})
|
||||
.from(missionTasks)
|
||||
.where(and(scopeClause, cursorClause, isNotNull(missionTasks.notes)))
|
||||
.orderBy(desc(missionTasks.createdAt), desc(missionTasks.id))
|
||||
.limit(rowLimit);
|
||||
|
||||
return sortRows(rows.filter((row) => row.content !== '') as RowObject[]);
|
||||
}
|
||||
|
||||
private async listMemory(
|
||||
filter: FederationScopeQueryFilter,
|
||||
rowLimit: number,
|
||||
cursor: KeysetCursor | undefined,
|
||||
): Promise<RowObject[]> {
|
||||
if (!filter.includePersonal) {
|
||||
return [];
|
||||
}
|
||||
if (cursor && cursor.source === undefined) {
|
||||
throw new Error('Invalid federation list cursor');
|
||||
}
|
||||
|
||||
const rows: RowObject[] = [];
|
||||
|
||||
// Memory spans two physical tables. To keep pagination deterministic and
|
||||
// resumable without a SQL UNION, M3 emits a fixed block order: all insights
|
||||
// first, then preferences. The opaque cursor records which table produced
|
||||
// the boundary row, so the next page never re-applies one table's keyset to
|
||||
// the other table (which could duplicate/skip rows at equal timestamps).
|
||||
if (cursor?.source !== 'preferences') {
|
||||
const insightCursorClause = cursor
|
||||
? or(
|
||||
lt(insights.createdAt, cursor.createdAt),
|
||||
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
const insightRows = await this.db
|
||||
.select({
|
||||
id: insights.id,
|
||||
kind: insights.source,
|
||||
content: insights.content,
|
||||
category: insights.category,
|
||||
relevanceScore: insights.relevanceScore,
|
||||
metadata: insights.metadata,
|
||||
createdAt: insights.createdAt,
|
||||
updatedAt: insights.updatedAt,
|
||||
})
|
||||
.from(insights)
|
||||
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
|
||||
.orderBy(desc(insights.createdAt), desc(insights.id))
|
||||
.limit(rowLimit);
|
||||
|
||||
rows.push(...(insightRows as RowObject[]).map((row) => markCursorSource(row, 'insights')));
|
||||
}
|
||||
|
||||
const remaining = rowLimit - rows.length;
|
||||
if (remaining <= 0) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const preferenceCursorClause =
|
||||
cursor?.source === 'preferences'
|
||||
? or(
|
||||
lt(preferences.createdAt, cursor.createdAt),
|
||||
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
|
||||
)
|
||||
: undefined;
|
||||
const preferenceRows = await this.db
|
||||
.select({
|
||||
id: preferences.id,
|
||||
kind: preferences.category,
|
||||
key: preferences.key,
|
||||
value: preferences.value,
|
||||
source: preferences.source,
|
||||
mutable: preferences.mutable,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
})
|
||||
.from(preferences)
|
||||
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
|
||||
.orderBy(desc(preferences.createdAt), desc(preferences.id))
|
||||
.limit(remaining);
|
||||
|
||||
rows.push(
|
||||
...(preferenceRows as RowObject[]).map((row) => markCursorSource(row, 'preferences')),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
147
apps/gateway/src/federation/server/verbs/list.controller.ts
Normal file
147
apps/gateway/src/federation/server/verbs/list.controller.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Federation list verb (FED-M3-05).
|
||||
*
|
||||
* POST /api/federation/v1/list/:resource
|
||||
*
|
||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||
* the read-only query layer returns capped rows tagged with `_source`. Read
|
||||
* audit-log writes are deferred to M4; this controller does not persist request
|
||||
* or response bodies.
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FederationInvalidRequestError,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
SOURCE_LOCAL,
|
||||
tagWithSource,
|
||||
type FederationListResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
import { FederationScopeService } from '../scope.service.js';
|
||||
import { FederationListQueryService } from './list-query.service.js';
|
||||
|
||||
interface FederationListRequestBody {
|
||||
readonly limit?: unknown;
|
||||
readonly cursor?: unknown;
|
||||
}
|
||||
|
||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||
|
||||
function parseLimit(body: FederationListRequestBody | undefined): number | undefined {
|
||||
if (body?.limit === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed =
|
||||
typeof body.limit === 'number'
|
||||
? body.limit
|
||||
: typeof body.limit === 'string' && body.limit.trim().length > 0
|
||||
? Number(body.limit)
|
||||
: Number.NaN;
|
||||
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError(
|
||||
'Federation list limit must be a positive integer',
|
||||
).toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCursor(body: FederationListRequestBody | undefined): string | undefined {
|
||||
if (body?.cursor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof body.cursor === 'string') {
|
||||
return body.cursor;
|
||||
}
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
@Controller('api/federation/v1/list')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class ListController {
|
||||
constructor(
|
||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||
@Inject(FederationListQueryService) private readonly query: FederationListQueryService,
|
||||
) {}
|
||||
|
||||
@Post(':resource')
|
||||
async list(
|
||||
@Param('resource') resource: string,
|
||||
@Req() request: FastifyRequest,
|
||||
@Body() body?: FederationListRequestBody,
|
||||
): Promise<FederationListResponse<FederatedRow>> {
|
||||
if (!request.federationContext) {
|
||||
throw new HttpException(
|
||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const requestedLimit = parseLimit(body);
|
||||
const cursor = parseCursor(body);
|
||||
const scopeResult = await this.scope.evaluateAccess({
|
||||
context: request.federationContext,
|
||||
resource,
|
||||
requestedLimit,
|
||||
nativeRbac: this.query,
|
||||
});
|
||||
|
||||
if (!scopeResult.allowed) {
|
||||
const ErrorClass =
|
||||
scopeResult.deny.statusCode === 400
|
||||
? FederationInvalidRequestError
|
||||
: FederationScopeViolationError;
|
||||
throw new HttpException(
|
||||
new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(),
|
||||
scopeResult.deny.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<FederationListQueryService['list']>>;
|
||||
try {
|
||||
result = await this.query.list({ filter: scopeResult.filter, cursor });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message === 'Invalid federation list cursor') {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response: FederationListResponse<FederatedRow> = {
|
||||
items: tagWithSource(result.items, SOURCE_LOCAL),
|
||||
};
|
||||
if (result.nextCursor !== undefined) {
|
||||
response.nextCursor = result.nextCursor;
|
||||
}
|
||||
if (result.truncated) {
|
||||
response._truncated = true;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: GC_QUEUE_HANDLE,
|
||||
useFactory: (): QueueHandle => {
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
||||
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: REDIS,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
inject: [GC_QUEUE_HANDLE],
|
||||
},
|
||||
SessionGCService,
|
||||
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
exports: [SessionGCService],
|
||||
})
|
||||
export class GCModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(GC_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle.close().catch(() => {});
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnModuleInit } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
@@ -32,11 +32,21 @@ export class SessionGCService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SessionGCService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
// On Local tier there is no Redis — the GC module provides null for this token.
|
||||
// NOTE: if a future feature stores Redis-backed state on Local tier, this guard
|
||||
// would silently skip GC for those keys. Revisit when that happens.
|
||||
@Optional()
|
||||
@Inject(REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (!this.redis) {
|
||||
// Local tier: no Valkey — skip cold-start GC entirely (correct no-op).
|
||||
this.logger.log('SessionGCService: Valkey GC skipped on local tier (no Redis configured)');
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget: run full GC asynchronously so it does not block the
|
||||
// NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms
|
||||
// depending on Valkey key count; deferring it removes that latency from
|
||||
@@ -60,8 +70,10 @@ export class SessionGCService implements OnModuleInit {
|
||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||
* duration, which can cause latency spikes under production key volumes.
|
||||
* Returns empty array when Redis is not available (Local tier).
|
||||
*/
|
||||
private async scanKeys(pattern: string): Promise<string[]> {
|
||||
if (!this.redis) return [];
|
||||
const collected: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
@@ -78,13 +90,15 @@ export class SessionGCService implements OnModuleInit {
|
||||
async collect(sessionId: string): Promise<GCResult> {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier)
|
||||
if (this.redis) {
|
||||
const pattern = `mosaic:session:${sessionId}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||
const cutoff = new Date(); // demote all hot logs for this session
|
||||
@@ -106,6 +120,7 @@ export class SessionGCService implements OnModuleInit {
|
||||
const cleaned: GCResult[] = [];
|
||||
|
||||
// 1. Find all session-scoped Valkey keys (non-blocking SCAN)
|
||||
// Returns empty on Local tier — no Valkey session keys exist there.
|
||||
const allSessionKeys = await this.scanKeys('mosaic:session:*');
|
||||
|
||||
// Extract unique session IDs from keys
|
||||
@@ -136,12 +151,16 @@ export class SessionGCService implements OnModuleInit {
|
||||
*/
|
||||
async fullCollect(): Promise<FullGCResult> {
|
||||
const start = Date.now();
|
||||
let valkeyKeysCount = 0;
|
||||
|
||||
if (this.redis) {
|
||||
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
|
||||
const sessionKeys = await this.scanKeys('mosaic:session:*');
|
||||
if (sessionKeys.length > 0) {
|
||||
await this.redis.del(...sessionKeys);
|
||||
}
|
||||
valkeyKeysCount = sessionKeys.length;
|
||||
}
|
||||
|
||||
// 2. NOTE: channel keys are NOT collected on cold start
|
||||
// (discord/telegram plugins may reconnect and resume)
|
||||
@@ -154,7 +173,7 @@ export class SessionGCService implements OnModuleInit {
|
||||
const jobsPurged = 0;
|
||||
|
||||
return {
|
||||
valkeyKeys: sessionKeys.length,
|
||||
valkeyKeys: valkeyKeysCount,
|
||||
logsDemoted,
|
||||
jobsPurged,
|
||||
tempFilesRemoved: 0,
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@@ -28,6 +28,16 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// On Local tier BullMQ is disabled — skip all job scheduling.
|
||||
// NOTE: this means summarization, tier management, and Valkey GC jobs do not
|
||||
// run on Local installs. For a single-user local install this is acceptable.
|
||||
// If periodic background work is needed on Local in the future, add a
|
||||
// setInterval-based scheduler here.
|
||||
if (!this.queueService.isEnabled()) {
|
||||
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
|
||||
return;
|
||||
}
|
||||
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
|
||||
@@ -42,7 +52,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||
await this.summarization.runSummarization();
|
||||
});
|
||||
this.registeredWorkers.push(summarizationWorker);
|
||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||
|
||||
// M6-005: Tier management repeatable job
|
||||
await this.queueService.addRepeatableJob(
|
||||
@@ -54,14 +64,14 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||
await this.summarization.runTierManagement();
|
||||
});
|
||||
this.registeredWorkers.push(tierWorker);
|
||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||
|
||||
// M6-004: GC repeatable job
|
||||
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
|
||||
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
|
||||
await this.sessionGC.sweepOrphans();
|
||||
});
|
||||
this.registeredWorkers.push(gcWorker);
|
||||
if (gcWorker) this.registeredWorkers.push(gcWorker);
|
||||
|
||||
this.logger.log(
|
||||
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||
|
||||
@@ -20,10 +20,12 @@ import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import helmet from '@fastify/helmet';
|
||||
import { listSsoStartupWarnings } from '@mosaicstack/auth';
|
||||
import { loadConfig } from '@mosaicstack/config';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
@@ -32,6 +34,20 @@ async function bootstrap(): Promise<void> {
|
||||
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()) {
|
||||
logger.warn(warning);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
|
||||
@@ -11,16 +13,54 @@ interface OverrideFragment {
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemOverrideService {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle;
|
||||
interface LocalOverrideEntry {
|
||||
condensed: string;
|
||||
fragments: OverrideFragment[];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@Injectable()
|
||||
export class SystemOverrideService implements OnApplicationShutdown {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle | null;
|
||||
/**
|
||||
* In-memory fallback used on Local tier (no Redis).
|
||||
* NOTE: state is ephemeral — lost on restart. For Local single-user installs
|
||||
* this is acceptable; system overrides are re-applied at the next session.
|
||||
* This is a deliberate behavior change from the Redis-backed 7-day TTL.
|
||||
*/
|
||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||
this.handle = null;
|
||||
} else {
|
||||
this.handle = createQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
// On non-local tiers the constructor opens an ioredis connection; close it
|
||||
// on graceful shutdown to avoid leaking the handle (local tier is null).
|
||||
await this.handle?.close().catch(() => {});
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string): Promise<void> {
|
||||
if (!this.handle) {
|
||||
// Local tier: in-memory path
|
||||
const entry = this.localStore.get(sessionId) ?? { condensed: '', fragments: [] };
|
||||
entry.fragments.push({ text: override, addedAt: Date.now() });
|
||||
entry.condensed = await this.condenseOverrides(entry.fragments.map((f) => f.text));
|
||||
this.localStore.set(sessionId, entry);
|
||||
this.logger.debug(
|
||||
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load existing fragments
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
@@ -50,10 +90,17 @@ export class SystemOverrideService {
|
||||
}
|
||||
|
||||
async get(sessionId: string): Promise<string | null> {
|
||||
if (!this.handle) {
|
||||
return this.localStore.get(sessionId)?.condensed ?? null;
|
||||
}
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||
}
|
||||
|
||||
async renew(sessionId: string): Promise<void> {
|
||||
if (!this.handle) {
|
||||
// Local tier: no TTL to renew; entry persists until restart
|
||||
return;
|
||||
}
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
@@ -61,6 +108,11 @@ export class SystemOverrideService {
|
||||
}
|
||||
|
||||
async clear(sessionId: string): Promise<void> {
|
||||
if (!this.handle) {
|
||||
this.localStore.delete(sessionId);
|
||||
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
||||
return;
|
||||
}
|
||||
await this.handle.redis.del(
|
||||
SESSION_SYSTEM_KEY(sessionId),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||
|
||||
35
apps/gateway/src/queue/queue.service.spec.ts
Normal file
35
apps/gateway/src/queue/queue.service.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { QueueService } from './queue.service.js';
|
||||
|
||||
const localConfig = {
|
||||
queue: { type: 'local' },
|
||||
} as MosaicConfig;
|
||||
|
||||
describe('QueueService local tier', () => {
|
||||
it('disables BullMQ and treats queue operations as local no-ops', async () => {
|
||||
const service = new QueueService(null, localConfig);
|
||||
|
||||
expect(service.isEnabled()).toBe(false);
|
||||
expect(service.getQueue('mosaic-test')).toBeNull();
|
||||
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
|
||||
|
||||
await expect(
|
||||
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
|
||||
await expect(service.listJobs()).resolves.toEqual([]);
|
||||
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,22 +110,43 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly connection: ConnectionOptions;
|
||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(LOG_SERVICE)
|
||||
private readonly logService: LogService | null,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.connection = getConnection();
|
||||
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
||||
this.connection = this.enabled
|
||||
? getConnection()
|
||||
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
|
||||
}
|
||||
|
||||
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.enabled) {
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
} else {
|
||||
this.logger.log(
|
||||
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.enabled) {
|
||||
await this.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Queue helpers
|
||||
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Get or create a BullMQ Queue for the given queue name.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
||||
if (!this.enabled) return null;
|
||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||
if (!queue) {
|
||||
queue = new Queue<T>(name, { connection: this.connection });
|
||||
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Add a BullMQ repeatable job (cron-style).
|
||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||
* No-op on Local tier.
|
||||
*/
|
||||
async addRepeatableJob<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
data: T,
|
||||
cronExpression: string,
|
||||
): Promise<void> {
|
||||
const queue = this.getQueue<T>(queueName);
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const queue = this.getQueue<T>(queueName)!;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (queue as Queue<any>).add(jobName, data, {
|
||||
repeat: { pattern: cronExpression },
|
||||
@@ -165,8 +197,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Register a Worker for the given queue name with error handling and
|
||||
* exponential backoff.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||
registerWorker<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
handler: JobHandler<T>,
|
||||
): Worker<T> | null {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const worker = new Worker<T>(
|
||||
queueName,
|
||||
async (job) => {
|
||||
@@ -223,8 +265,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Return queue health statistics for all managed queues.
|
||||
* Returns an empty healthy result on Local tier.
|
||||
*/
|
||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||
if (!this.enabled) {
|
||||
return { queues: {}, healthy: true };
|
||||
}
|
||||
const queues: QueueHealthStatus['queues'] = {};
|
||||
let healthy = true;
|
||||
|
||||
@@ -255,8 +301,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* List jobs across all managed queues, optionally filtered by status.
|
||||
* BullMQ jobs are fetched by state type from each queue.
|
||||
* Returns empty array on Local tier.
|
||||
*/
|
||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||
if (!this.enabled) return [];
|
||||
const jobs: JobDto[] = [];
|
||||
const states: JobStatus[] = status
|
||||
? [status]
|
||||
@@ -283,8 +331,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||
* job IDs are not globally unique — they are scoped to their queue.
|
||||
* Returns an error on Local tier.
|
||||
*/
|
||||
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const sep = compositeId.lastIndexOf('__');
|
||||
if (sep === -1) {
|
||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||
@@ -316,6 +366,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Pause a queue by name.
|
||||
*/
|
||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.pause();
|
||||
@@ -327,6 +378,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Resume a paused queue by name.
|
||||
*/
|
||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.resume();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import swc from 'unplugin-swc';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -5,4 +6,22 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
plugins: [
|
||||
swc.vite({
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
decorators: true,
|
||||
},
|
||||
transform: {
|
||||
decoratorMetadata: true,
|
||||
legacyDecorator: true,
|
||||
},
|
||||
target: 'es2022',
|
||||
},
|
||||
module: {
|
||||
type: 'nodenext',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
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:
|
||||
28
docker/appservice.Dockerfile
Normal file
28
docker/appservice.Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
# Copy workspace manifests first for layer-cached install
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/appservice/package.json ./apps/appservice/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
||||
RUN pnpm --filter @mosaicstack/mosaic-as --prod deploy --legacy /deploy
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /deploy/node_modules ./node_modules
|
||||
COPY --from=builder /deploy/package.json ./package.json
|
||||
COPY --from=builder /app/apps/appservice/dist ./dist
|
||||
USER node
|
||||
EXPOSE 8008
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=5 \
|
||||
CMD ["node", "-e", "require('http').get('http://127.0.0.1:8008/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -5,18 +5,27 @@ RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
# Copy workspace manifests first for layer-cached install
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/gateway/package.json ./apps/gateway/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
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
|
||||
WORKDIR /app
|
||||
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/package.json ./package.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
EXPOSE 4000
|
||||
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.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
> Top-level rollup tracking Mosaic Stack MVP execution.
|
||||
> Workstreams have their own manifests; this document is the source of truth for MVP scope, status, and history.
|
||||
> Owner: Orchestrator (sole writer).
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** harness-20260321
|
||||
**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.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** All milestones done
|
||||
**Progress:** 7 / 7 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-03-22 UTC
|
||||
**ID:** mvp-20260312
|
||||
**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:** Execution (workstream W1 in planning-complete state)
|
||||
**Current Workstream:** W1 — Federation v1
|
||||
**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||
**Status:** active (continuous since 2026-03-13)
|
||||
**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
|
||||
|
||||
- [x] AC-1: Send messages in TUI → restart TUI → resume conversation → agent has full history and context
|
||||
- [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
|
||||
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).
|
||||
|
||||
## 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 |
|
||||
| --- | ------ | ---------------------------------- | ------ | ------ | --------- | ---------- | ---------- |
|
||||
| 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 |
|
||||
## Workstreams
|
||||
|
||||
## 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 |
|
||||
| -------------------- | --------- | -------------------------- |
|
||||
| Docker Compose (dev) | localhost | docker compose up |
|
||||
| Production | TBD | Docker Swarm via Portainer |
|
||||
### Likely Additional Workstreams (Not Yet Declared)
|
||||
|
||||
## 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
|
||||
- **Sibling Agents:** sonnet (workers), haiku (verification)
|
||||
- **Shared Contracts:** docs/PRD-Harness_Foundation.md, docs/TASKS.md
|
||||
- Web dashboard parity with PRD scope (chat, tasks, projects, missions, agent status surfaces)
|
||||
- Pi TUI integration for terminal-native agent work
|
||||
- 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 |
|
||||
| ------ | ------ |
|
||||
| Budget | — |
|
||||
| Used | ~2.5M |
|
||||
| Mode | normal |
|
||||
## Risks
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ---------- | -------- | ------------ | ----------------- |
|
||||
| 1 | claude-opus-4-6 | 2026-03-21 | ~6h | complete | M7-008 — all done |
|
||||
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.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -64,6 +64,7 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
||||
21. `@mosaicstack/cli` — unified `mosaic` CLI
|
||||
22. Docker Compose deployment + bare-metal capability
|
||||
23. Agent log service — ingest, parse, tier, summarize agent interaction logs
|
||||
24. Local durable agent fleet canary — `mosaic fleet` / `mosaic agent` CLI for an isolated tmux-backed canary fleet using a named socket, with roster-driven local customization and rollback-safe verification
|
||||
|
||||
### Out of Scope (v0.1.0)
|
||||
|
||||
|
||||
112
docs/TASKS.md
112
docs/TASKS.md
@@ -1,30 +1,92 @@
|
||||
# Tasks — Storage Abstraction Retrofit
|
||||
# Tasks — MVP (Top-Level Rollup)
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** Decouple gateway from hardcoded Postgres/Valkey backends. Introduce interface-driven middleware so the gateway is backend-agnostic. Default to local tier (SQLite + JSON) for zero-dependency installs.
|
||||
> **Mission:** mvp-20260312
|
||||
> **Manifest:** [docs/MISSION-MANIFEST.md](./MISSION-MANIFEST.md)
|
||||
>
|
||||
> **`agent` column values:** `codex` | `sonnet` | `haiku` | `glm-5` | `opus` | `—` (auto/default)
|
||||
> This file is a **rollup**. Per-workstream task breakdowns live in workstream task files
|
||||
> (e.g. `docs/federation/TASKS.md`). Workers operating inside a workstream should treat
|
||||
> the workstream file as their primary task source; this file exists for orchestrator-level
|
||||
> visibility into MVP-wide state.
|
||||
>
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
||||
|
||||
| id | status | agent | description | tokens |
|
||||
| --------- | ----------- | ------ | ---------------------------------------------------------------- | ------ |
|
||||
| SA-P1-001 | done | sonnet | Define QueueAdapter interface in packages/queue/src/types.ts | 3K |
|
||||
| SA-P1-002 | done | sonnet | Define StorageAdapter interface in packages/storage/src/types.ts | 3K |
|
||||
| SA-P1-003 | done | sonnet | Define MemoryAdapter interface in packages/memory/src/types.ts | 3K |
|
||||
| SA-P1-004 | done | sonnet | Create adapter factory pattern + config types | 3K |
|
||||
| SA-P2-001 | done | sonnet | Refactor @mosaicstack/queue: wrap ioredis as BullMQ adapter | 3K |
|
||||
| SA-P2-002 | done | sonnet | Create @mosaicstack/storage: wrap Drizzle as Postgres adapter | 6K |
|
||||
| SA-P2-003 | done | sonnet | Refactor @mosaicstack/memory: extract pgvector adapter | 4K |
|
||||
| SA-P2-004 | done | sonnet | Update gateway modules to use factories + DI tokens | 5K |
|
||||
| SA-P2-005 | done | opus | Verify Phase 2: all tests pass, typecheck clean | — |
|
||||
| SA-P3-001 | done | sonnet | Implement local queue adapter: JSON file persistence | 5K |
|
||||
| SA-P3-002 | done | sonnet | Implement SQLite storage adapter with better-sqlite3 | 8K |
|
||||
| SA-P3-003 | done | sonnet | Implement keyword memory adapter — no vector dependency | 4K |
|
||||
| SA-P3-004 | done | opus | Verify Phase 3: 42 new tests, 347 total passing | — |
|
||||
| SA-P4-001 | done | sonnet | MosaicConfig schema + loader with tier auto-detection | 6K |
|
||||
| SA-P4-002 | done | sonnet | CLI: mosaic gateway init — interactive wizard | 4K |
|
||||
| SA-P4-003 | done | sonnet | CLI: mosaic gateway start/stop/status lifecycle | 5K |
|
||||
| SA-P4-004 | done | opus | Verify Phase 4: 381 tests passing, 40/40 tasks clean | — |
|
||||
| SA-P5-001 | not-started | codex | Migration tooling: mosaic storage export/import | — |
|
||||
| SA-P5-002 | not-started | codex | Docker Compose profiles: local vs team | — |
|
||||
| SA-P5-003 | not-started | codex | Final verification + docs: README, architecture diagram | — |
|
||||
## Workstream Rollup
|
||||
|
||||
| id | status | workstream | progress | tasks file | notes |
|
||||
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||
|
||||
## Cross-Cutting Tracking
|
||||
|
||||
These are MVP-level checks that don't belong to any single workstream. Updated by the orchestrator at each session.
|
||||
|
||||
| id | status | description | notes |
|
||||
| ---------- | ----------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MVP-T01 | done | Author MVP-level manifest at `docs/MISSION-MANIFEST.md` | This session (2026-04-19); PR pending |
|
||||
| MVP-T02 | done | Archive install-ux-v2 mission state to `docs/archive/missions/install-ux-v2-20260405/` | IUV-M03 retroactively closed (shipped via PR #446 + releases 0.0.27→0.0.29) |
|
||||
| MVP-T03 | done | Land federation v1 planning artifacts on `main` | PR #468 merged 2026-04-19 (commit `66512550`) |
|
||||
| MVP-T04 | not-started | Sync `.mosaic/orchestrator/mission.json` MVP slot with this manifest (milestone enumeration, etc.) | Coord state file; consider whether to repopulate via `mosaic coord` or accept hand-edit |
|
||||
| MVP-T05 | in-progress | Kick off W1 / FED-M1 — federated tier infrastructure | Session 16 (2026-04-19): FED-M1-01 in-progress on `feat/federation-m1-tier-config` |
|
||||
| MVP-T06 | not-started | Declare additional workstreams (web dashboard, TUI/CLI parity, remote control, etc.) as scope solidifies | Track each new workstream by adding a row to the Workstream Rollup |
|
||||
| T-A292E96F | in-progress | Fix Mosaic Gitea PR metadata/login wrapper regression for U-Connect merge preflight | Kanban `t_a292e96f`; branch `fix/t-a292e96f-gitea-pr-metadata`; scratchpad `docs/scratchpads/t-a292e96f-gitea-pr-metadata.md` |
|
||||
|
||||
## Pointer to Active Workstream
|
||||
|
||||
Active workstream is **W1 — Federation v1**. Workers should:
|
||||
|
||||
1. Read [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) for workstream scope
|
||||
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
||||
3. Follow per-task agent + tier guidance from the workstream manifest
|
||||
|
||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||
|
||||
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
||||
- Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (−53%); all 12 hard gates intact.
|
||||
- Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md.
|
||||
|
||||
## P5 — Overlay composer + cross-harness (#604) — feat/p5-overlay-composer
|
||||
|
||||
- Status: MERGED to main (#605). R7 (compose-contract) + R8 (cross-harness) + R9 (composer test).
|
||||
- `composeContract({harness, mosaicHome})` pure fn + `.local` overlay deltas-by-value; `mosaic compose-contract <harness>` command; AGENTS bare-launch nudge; composer spec (per-tier anchor + Tier-3 byte-equality). Detail: scratchpads/p5-overlay-composer.md.
|
||||
|
||||
## P6 — Docs, compliance matrix, alpha tag (#606) — feat/p6-docs-compliance-alpha
|
||||
|
||||
- Status: in-repo deliverables done (CONTRIBUTING.md + harness×gate compliance matrix + check-resident-budget.sh + CI wiring + ALPHA-DOD.md). Remaining: alpha tag v0.0.39-alpha (Lead, post-merge). aiguide reconcile merged (#8). Detail: scratchpads/p6-docs-compliance-alpha.md.
|
||||
|
||||
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
|
||||
|
||||
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
|
||||
|
||||
## Fleet-polish bundle — boot-survival symmetry (#611) — feat/fleet-polish-bundle
|
||||
|
||||
- Status: MERGED to main. disable-on-remove (boot-resurrection bug, TDD) + add-enable + init-R5 hard guarantee. 4 new + 147 existing fleet tests green. Detail: scratchpads/fleet-polish-bundle.md.
|
||||
|
||||
## Fleet enhancer role + two-agent floor (#614) — feat/fleet-enhancer-floor
|
||||
|
||||
- Status: MERGED to main. enhancer added to 4 presets; init guarantees 1 orchestrator + >=1 enhancer; remove protects the sole enhancer; enhancer role doc. 155 fleet tests green. Detail: scratchpads/fleet-enhancer-floor.md.
|
||||
|
||||
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
|
||||
|
||||
- Status: Phase 1 MERGED (#617: connector interface send/subscribe/health + registry + roster schema + design). Phase 2a (#618): Matrix CS-API client + factory. 20 connector tests green; no fleet.ts changes. Remaining Phase 2: init/configure connector-selection UX + roster wiring, systemd launch wiring, Conduit deploy guide. Detail: scratchpads/f4-matrix-connector.md.
|
||||
|
||||
## Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) — feat/fleet-comms-onboarding
|
||||
|
||||
- Status: implemented + tested. Injects # Fleet Comms (peer roster + cross-host agent-send commands + FLIP-reply + --verify) into each spawned fleet agent via composeContract; optional per-agent host/ssh/socket roster fields (socket: named → -L, unset → default socket no -L). 10 + 2 tests green. Detail: scratchpads/fleet-comms-onboarding.md.
|
||||
|
||||
## Fleet stand-up fixes — model_hint→--model + socket-default trap (#626) — feat/fleet-standup-fixes
|
||||
|
||||
- Status: implemented + tested. FIX1 model_hint→MOSAIC_AGENT_MODEL→--model. FIX2 absent socket = default tmux socket (no -L) across parse/spawn/systemd-unit/observe (socketArgs helper, bare-empty shellEnvValue, conditional -L). 158 fleet tests green; shipped presets unaffected (explicit socket_name). Detail: scratchpads/fleet-standup-fixes.md.
|
||||
|
||||
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
||||
|
||||
- Status: applied Mos's consolidated merge-map to docs/fleet/north-star.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
||||
|
||||
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
|
||||
|
||||
- Status: implemented + tested. PRIMARY: install.sh PRESERVE_PATHS += fleet/\*.yaml + fleet/agents + fleet/run (glob-aware cp-fallback); TS parity. SECONDARY: refreshActiveFleetUnits propagates unit fixes to ~/.config/systemd/user on mosaic update. bash F6 + TS + unit tests green. Detail: scratchpads/631-reseed-preserves-fleet.md.
|
||||
|
||||
## #633 — comms-block emitter + FLEET-LAUNCH runbook — feat/633-comms-block-runbook
|
||||
|
||||
- Status: implemented + tested (TDD). `mosaic fleet comms-block <role> [--host]` wraps resolveCommsBlock → readFleetCommsBlock; fails loud (stderr + exit 1) on unknown role / missing roster instead of silent empty. docs/fleet/FLEET-LAUNCH.md runbook: worker path + orchestrator .env fold (MOSAIC_AGENT_COMMAND; line-41 [-z] short-circuits line-44 yolo hardcode) + 3 launch gotchas + #632 preserve note + North-Star 4-field arc (harness ✅/model ✅ roster-native today; yolo + command/channels = PATH B #636). 177 fleet+comms tests green (6 new resolveCommsBlock cases). PATH A of the A→B→webUI arc. Detail: scratchpads/633-comms-block-runbook.md.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Mission Manifest — CLI Unification & E2E First-Run
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** cli-unification-20260404
|
||||
**Statement:** Transform the Mosaic CLI from a partially-duplicated, manually-assembled experience into a single cohesive entry point that installs, configures, and controls the entire Mosaic system. Every Mosaic package gets first-class CLI surface. The first-run experience works end-to-end with no manual stitching. Gateway token recovery is possible without the web UI. Opt-in telemetry uses the published telemetry clients.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** —
|
||||
**Progress:** 8 / 8 milestones
|
||||
**Status:** completed
|
||||
**Last Updated:** 2026-04-05
|
||||
**Release:** [`mosaic-v0.0.24`](https://git.mosaicstack.dev/mosaicstack/mosaic-stack/releases/tag/mosaic-v0.0.24) (`@mosaicstack/mosaic@0.0.24`, alpha — stays in 0.0.x until GA)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: Fresh machine `bash <(curl …install.sh)` → single command lands on a working authenticated gateway with a usable admin token; no secondary manual wizards required
|
||||
- [x] AC-2: `mosaic --help` lists every sub-package as a top-level command and is alphabetized for readability
|
||||
- [x] AC-3: `mosaic auth`, `mosaic brain`, `mosaic forge`, `mosaic log`, `mosaic macp`, `mosaic memory`, `mosaic queue`, `mosaic storage`, `mosaic telemetry` each expose at least one working subcommand that exercises the underlying package
|
||||
- [x] AC-4: Gateway admin token can be rotated or recovered from the CLI alone — operator is never stranded because the web UI is inaccessible
|
||||
- [x] AC-5: `mosaic telemetry` uses the published `@mosaicstack/telemetry-client-js` (from the Gitea npm registry); local OTEL stays for wide-event logging / post-mortems; remote upload is opt-in and disabled by default
|
||||
- [x] AC-6: Install → wizard → gateway install → TUI verification flow is a single cohesive path with clear state transitions and no dead ends
|
||||
- [x] AC-7: `@mosaicstack/mosaic` is the sole `mosaic` binary owner; `@mosaicstack/cli` is gone from the repo and all docs
|
||||
- [x] AC-8: All milestones ship as merged PRs with green CI, closed issues, and updated release notes
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------ | ------------------------------------------------------------------------ | ------ | ----------------------------------- | --------------------------------- | ---------- | ---------- |
|
||||
| 1 | cu-m01 | Kill legacy @mosaicstack/cli package | done | chore/remove-cli-package-duplicate | #398 | 2026-04-04 | 2026-04-04 |
|
||||
| 2 | cu-m02 | Archive stale mission state + scaffold new mission | done | docs/mission-cli-unification | #399 | 2026-04-04 | 2026-04-04 |
|
||||
| 3 | cu-m03 | Fix gateway bootstrap token recovery (server + CLI paths) | done | feat/gateway-token-recovery | #411, #414 | 2026-04-05 | 2026-04-05 |
|
||||
| 4 | cu-m04 | Alphabetize + group `mosaic --help` output | done | feat/help-sort + feat/mosaic-config | #402, #408 | 2026-04-05 | 2026-04-05 |
|
||||
| 5 | cu-m05 | Sub-package CLI surface (auth/brain/forge/log/macp/memory/queue/storage) | done | feat/mosaic-\*-cli (x9) | #403–#407, #410, #412, #413, #415 | 2026-04-05 | 2026-04-05 |
|
||||
| 6 | cu-m06 | `mosaic telemetry` — local OTEL + opt-in remote upload | done | feat/mosaic-telemetry | #417 | 2026-04-05 | 2026-04-05 |
|
||||
| 7 | cu-m07 | Unified first-run UX (install.sh → wizard → gateway → TUI) | done | feat/mosaic-first-run-ux | #418 | 2026-04-05 | 2026-04-05 |
|
||||
| 8 | cu-m08 | Docs refresh + release tag | done | docs/cli-unification-release-v0.1.0 | #419 | 2026-04-05 | 2026-04-05 |
|
||||
|
||||
## Deployment
|
||||
|
||||
| Target | URL | Method |
|
||||
| -------------------- | --------- | ----------------------------------------------- |
|
||||
| Local tier (default) | localhost | `mosaic gateway install` — pglite + local queue |
|
||||
| Team tier | any host | `mosaic gateway install` — PG + Valkey |
|
||||
| Docker Compose (dev) | localhost | `docker compose up` for PG/Valkey/OTEL/Jaeger |
|
||||
|
||||
## Coordination
|
||||
|
||||
- **Primary Agent:** claude-opus-4-6[1m]
|
||||
- **Sibling Agents:** sonnet (standard implementation), haiku (status/explore/verify), codex (coding-heavy tasks)
|
||||
- **Shared Contracts:** `docs/PRD.md` (existing v0.1.0 PRD — still the long-term target), this manifest, `docs/TASKS.md`, `docs/scratchpads/cli-unification-20260404.md`
|
||||
|
||||
## Token Budget
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ------ |
|
||||
| Budget | TBD |
|
||||
| Used | ~80K |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ---------- | -------- | ---------------- | ------------------------------------------------------------ |
|
||||
| 1 | claude-opus-4-6 | 2026-04-04 | ~4h | context-budget | cu-m01 + cu-m02 merged (#398, #399); open questions resolved |
|
||||
| 2 | claude-opus-4-6 | 2026-04-05 | ~6h | mission-complete | cu-m03..cu-m08 all merged; mosaic-v0.1.0 released |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/cli-unification-20260404.md`
|
||||
90
docs/archive/missions/cli-unification-20260404/TASKS.md
Normal file
90
docs/archive/missions/cli-unification-20260404/TASKS.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Tasks — CLI Unification & E2E First-Run
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** cli-unification-20260404
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `glm-5` | `—` (auto)
|
||||
|
||||
## Milestone 1 — Kill legacy @mosaicstack/cli (done)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ----------------------------------------------------------------- | ----- | ----- | ---------------------------------- | ---------- | -------- | --------------------------- |
|
||||
| CU-01-01 | done | Delete packages/cli directory; update workspace + docs references | #398 | opus | chore/remove-cli-package-duplicate | — | 5K | Merged c39433c3. 6685 LOC−. |
|
||||
|
||||
## Milestone 2 — Archive stale mission + scaffold new mission (done)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ------------------------------------------------------------------ | ----- | ----- | ---------------------------- | ---------- | -------- | --------------------------------- |
|
||||
| CU-02-01 | done | Move stale MISSION-MANIFEST / TASKS / PRD-Harness to docs/archive/ | #399 | opus | docs/mission-cli-unification | CU-01-01 | 3K | Harness + storage missions done. |
|
||||
| CU-02-02 | done | Scaffold new MISSION-MANIFEST.md, TASKS.md, scratchpad | #399 | opus | docs/mission-cli-unification | CU-02-01 | 5K | This file + manifest + scratchpad |
|
||||
| CU-02-03 | done | PR review, merge, branch cleanup | #399 | opus | docs/mission-cli-unification | CU-02-02 | 2K | Merged as 6f15a84c |
|
||||
|
||||
## Milestone 3 — Gateway bootstrap token recovery
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----------------------------- |
|
||||
| CU-03-01 | done | Implementation plan for BetterAuth-cookie recovery flow (decision locked 2026-04-04) | — | opus | — | CU-02-03 | 4K | Design locked; plan-only task |
|
||||
| CU-03-02 | done | Server: add recovery/rotate endpoint on apps/gateway/src/admin (gated by design from CU-03-01) | — | sonnet | — | CU-03-01 | 12K | |
|
||||
| CU-03-03 | done | CLI: `mosaic gateway login` — interactive BetterAuth sign-in, persist session | — | sonnet | — | CU-03-02 | 10K | |
|
||||
| CU-03-04 | done | CLI: `mosaic gateway config rotate-token` — mint new admin token via authenticated API | — | sonnet | — | CU-03-03 | 8K | |
|
||||
| CU-03-05 | done | CLI: `mosaic gateway config recover-token` — execute the recovery flow from CU-03-01 | — | sonnet | — | CU-03-03 | 10K | |
|
||||
| CU-03-06 | done | Install UX: fix the "user exists, no token" dead-end in runInstall bootstrapFirstUser path | — | sonnet | — | CU-03-05 | 8K | |
|
||||
| CU-03-07 | done | Tests: integration tests for each recovery path (happy + error) | — | sonnet | — | CU-03-06 | 10K | |
|
||||
| CU-03-08 | done | Code review + remediation | — | haiku | — | CU-03-07 | 4K | |
|
||||
|
||||
## Milestone 4 — `mosaic --help` alphabetize + grouping
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------------------- |
|
||||
| CU-04-01 | done | Enable `configureHelp({ sortSubcommands: true })` on root program and each subgroup | — | sonnet | — | CU-02-03 | 3K | |
|
||||
| CU-04-02 | done | Group commands into sections (Runtime, Gateway, Framework, Platform) in help output | — | sonnet | — | CU-04-01 | 5K | |
|
||||
| CU-04-03 | done | Verify help snapshots render readably; update any docs with stale output | — | haiku | — | CU-04-02 | 3K | |
|
||||
| CU-04-04 | done | Top-level `mosaic config` command — `show`, `get <key>`, `set <key> <val>`, `edit`, `path` — wraps packages/mosaic/src/config/config-service.ts (framework/agent config; distinct from `mosaic gateway config`) | — | sonnet | — | CU-02-03 | 10K | New scope (decision 2026-04-04) |
|
||||
| CU-04-05 | done | Tests + code review for CU-04-04 | — | haiku | — | CU-04-04 | 4K | |
|
||||
|
||||
## Milestone 5 — Sub-package CLI surface
|
||||
|
||||
> Pattern: each sub-package exports `register<Name>Command(program: Command)` co-located with the library code (proven by `@mosaicstack/quality-rails`). Wire into `packages/mosaic/src/cli.ts`.
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | --------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------- |
|
||||
| CU-05-01 | done | `mosaic forge` — subcommands: `run`, `status`, `resume`, `personas list` | — | sonnet | — | CU-02-03 | 18K | User priority |
|
||||
| CU-05-02 | done | `mosaic storage` — subcommands: `status`, `tier show`, `tier switch`, `export`, `import`, `migrate` | — | sonnet | — | CU-02-03 | 15K | |
|
||||
| CU-05-03 | done | `mosaic queue` — subcommands: `list`, `stats`, `pause/resume`, `jobs tail`, `drain` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-04 | done | `mosaic memory` — subcommands: `search`, `stats`, `insights list`, `preferences list` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-05 | done | `mosaic brain` — subcommands: `projects list/create`, `missions list`, `tasks list`, `conversations list` | — | sonnet | — | CU-02-03 | 15K | |
|
||||
| CU-05-06 | done | `mosaic auth` — subcommands: `users list/create/delete`, `sso list`, `sso test`, `sessions list` | — | sonnet | — | CU-03-03 | 15K | needs gateway login |
|
||||
| CU-05-07 | done | `mosaic log` — subcommands: `tail`, `search`, `export`, `level <level>` | — | sonnet | — | CU-02-03 | 10K | |
|
||||
| CU-05-08 | done | `mosaic macp` — subcommands: `tasks list`, `submit`, `gate`, `events tail` | — | sonnet | — | CU-02-03 | 12K | |
|
||||
| CU-05-09 | done | Wire all eight `register<Name>Command` calls into packages/mosaic/src/cli.ts | — | haiku | — | CU-05-01…8 | 3K | |
|
||||
| CU-05-10 | done | Integration test: `mosaic <cmd> --help` exits 0 for every new command | — | haiku | — | CU-05-09 | 5K | |
|
||||
|
||||
## Milestone 6 — `mosaic telemetry`
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ---------------------------------------------- |
|
||||
| CU-06-01 | done | Add `@mosaicstack/telemetry-client-js` as dependency of `@mosaicstack/mosaic` from Gitea registry | — | sonnet | — | CU-02-03 | 3K | |
|
||||
| CU-06-02 | done | `mosaic telemetry local` — status, tail, Jaeger link (wraps existing apps/gateway/src/tracing.ts) | — | sonnet | — | CU-06-01 | 8K | |
|
||||
| CU-06-03 | done | `mosaic telemetry` — status, opt-in, opt-out, test, upload (uses telemetry-client-js) | — | sonnet | — | CU-06-01 | 12K | Dry-run mode when server endpoint not yet live |
|
||||
| CU-06-04 | done | Persistent consent state in mosaic config; disabled by default | — | sonnet | — | CU-06-03 | 5K | |
|
||||
| CU-06-05 | done | Tests + code review | — | haiku | — | CU-06-04 | 5K | |
|
||||
|
||||
## Milestone 7 — Unified first-run UX
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||
| CU-07-01 | done | tools/install.sh: after npm install, hand off to `mosaic wizard` then `mosaic gateway install` | — | sonnet | — | CU-03-06 | 10K | |
|
||||
| CU-07-02 | done | `mosaic wizard` and `mosaic gateway install` coordination: shared state, no duplicate prompts | — | sonnet | — | CU-07-01 | 12K | |
|
||||
| CU-07-03 | done | Post-install verification step: "gateway healthy, tui connects, admin token on file" | — | sonnet | — | CU-07-02 | 8K | |
|
||||
| CU-07-04 | done | End-to-end test on a clean container from scratch | — | haiku | — | CU-07-03 | 8K | |
|
||||
|
||||
## Milestone 8 — Docs + release
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| -------- | ------ | ---------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||
| CU-08-01 | done | Update README.md with new command tree, install flow, and feature list | — | sonnet | — | CU-07-04 | 8K | |
|
||||
| CU-08-02 | done | Update docs/guides/user-guide.md with all new sub-package commands | — | sonnet | — | CU-08-01 | 10K | |
|
||||
| CU-08-03 | done | Version bump `@mosaicstack/mosaic`, publish to Gitea registry | — | opus | — | CU-08-02 | 3K | |
|
||||
| CU-08-04 | done | Release notes, tag `v0.1.0-rc.N`, publish release on Gitea | — | opus | — | CU-08-03 | 3K | |
|
||||
70
docs/archive/missions/harness-20260321/MISSION-MANIFEST.md
Normal file
70
docs/archive/missions/harness-20260321/MISSION-MANIFEST.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Mission Manifest — Harness Foundation
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** harness-20260321
|
||||
**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.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** All milestones done
|
||||
**Progress:** 7 / 7 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-03-22 UTC
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: Send messages in TUI → restart TUI → resume conversation → agent has full history and context
|
||||
- [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
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------ | ---------------------------------- | ------ | ------ | --------- | ---------- | ---------- |
|
||||
| 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
|
||||
|
||||
| Target | URL | Method |
|
||||
| -------------------- | --------- | -------------------------- |
|
||||
| Docker Compose (dev) | localhost | docker compose up |
|
||||
| Production | TBD | Docker Swarm via Portainer |
|
||||
|
||||
## Coordination
|
||||
|
||||
- **Primary Agent:** claude-opus-4-6
|
||||
- **Sibling Agents:** sonnet (workers), haiku (verification)
|
||||
- **Shared Contracts:** docs/PRD-Harness_Foundation.md, docs/TASKS.md
|
||||
|
||||
## Token Budget
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ------ |
|
||||
| Budget | — |
|
||||
| Used | ~2.5M |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ---------- | -------- | ------------ | ----------------- |
|
||||
| 1 | claude-opus-4-6 | 2026-03-21 | ~6h | complete | M7-008 — all done |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/harness-20260321.md`
|
||||
@@ -0,0 +1,57 @@
|
||||
# Mission Manifest — Install UX Hardening
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** install-ux-hardening-20260405
|
||||
**Statement:** Close the remaining gaps in the Mosaic Stack first-run and teardown experience uncovered by the post-`cli-unification` audit. A user MUST be able to cleanly uninstall the stack; the wizard MUST make security-sensitive surfaces visible (hooks, password entry); and CI/headless installs MUST NOT hang on interactive prompts. The longer-term goal is a single cohesive first-run flow that collapses `mosaic wizard` and `mosaic gateway install` into one state-bridged experience.
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** —
|
||||
**Progress:** 3 / 3 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-04-05 (mission complete)
|
||||
**Parent Mission:** [cli-unification-20260404](./archive/missions/cli-unification-20260404/MISSION-MANIFEST.md) (complete)
|
||||
|
||||
## Context
|
||||
|
||||
Post-merge audit of `cli-unification-20260404` (AC-1, AC-6) validated that the first-run wizard covers first user, password, admin tokens, gateway instance config, skills, and SOUL.md/USER.md init. The audit surfaced six gaps, grouped into three tracks of independent value.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: `mosaic uninstall` (top-level) cleanly reverses every mutation made by `tools/install.sh` — framework data, npm CLI, nested stack deps, runtime asset injections in `~/.claude/`, npmrc scope mapping, PATH edits. Dry-run supported. `--keep-data` preserves memory + user files + gateway DB. (PR #429)
|
||||
- [x] AC-2: `curl … | bash -s -- --uninstall` works without requiring a functioning CLI. (PR #429)
|
||||
- [x] AC-3: Password entry in `bootstrapFirstUser` is masked (no plaintext echo); confirm prompt added. (PR #431)
|
||||
- [x] AC-4: Wizard has an explicit hooks stage that previews which hooks will be installed, asks for confirmation, and records the user's choice. `mosaic config hooks list|enable|disable` surface exists. (PR #431 — consent; PR #433 — finalize-stage gating now honors `state.hooks.accepted === false` end-to-end)
|
||||
- [x] AC-5: `runConfigWizard` and `bootstrapFirstUser` accept a headless path (env vars + `--yes`) so `tools/install.sh --yes` + `MOSAIC_ASSUME_YES=1` completes end-to-end in CI without TTY. (PR #431)
|
||||
- [x] AC-6: `mosaic wizard` and `mosaic gateway install` are collapsed into a single cohesive entry point with shared state; gateway install is now terminal stages 11 & 12 of `runWizard`, session-file bridge removed, `mosaic gateway install` preserved as a thin standalone wrapper. (PR #433)
|
||||
- [x] AC-7: All milestones shipped as merged PRs with green CI and closed issues. (PRs #429, #431, #433)
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | --------------------------------------------------------- | ------ | ----------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | IUH-M01 | `mosaic uninstall` — top-level teardown + shell wrapper | done | feat/mosaic-uninstall | #425 | 2026-04-05 | 2026-04-05 |
|
||||
| 2 | IUH-M02 | Wizard remediation — hooks visibility, pwd mask, headless | done | feat/wizard-remediation | #426 | 2026-04-05 | 2026-04-05 |
|
||||
| 3 | IUH-M03 | Unified first-run wizard (collapse wizard + gateway) | done | feat/unified-first-run | #427 | 2026-04-05 | 2026-04-05 |
|
||||
|
||||
## Subagent Delegation Plan
|
||||
|
||||
| Milestone | Recommended Tier | Rationale |
|
||||
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
||||
| IUH-M01 | sonnet | Standard feature work — new command surface mirroring existing install |
|
||||
| IUH-M02 | sonnet | Small surgical fixes across 3-4 files |
|
||||
| IUH-M03 | opus | Architectural refactor; state machine design decisions |
|
||||
|
||||
## Risks
|
||||
|
||||
- **Reversal completeness** — runtime asset linking creates `.mosaic-bak-*` backups; uninstall must honor them vs. when to delete. Ambiguity without an install manifest.
|
||||
- **npm global nested deps** — `npm uninstall -g @mosaicstack/mosaic` removes nested `@mosaicstack/*`, but ownership conflicts with explicitly installed peer packages (`@mosaicstack/gateway`, `@mosaicstack/memory`) need test coverage.
|
||||
- **Headless bootstrap** — admin password via env var is a credential on disk; needs clear documentation that `MOSAIC_ADMIN_PASSWORD` is intended for CI-only and should be rotated post-install.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- `mosaicstack.dev/install.sh` vanity URL (blocked on marketing site work)
|
||||
- Uninstall for the `@mosaicstack/gateway` database contents — delegated to `mosaic gateway uninstall` semantics already in place
|
||||
- Signature/checksum verification of install scripts
|
||||
41
docs/archive/missions/install-ux-hardening-20260405/TASKS.md
Normal file
41
docs/archive/missions/install-ux-hardening-20260405/TASKS.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Tasks — Install UX Hardening
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** install-ux-hardening-20260405
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||
|
||||
## Milestone 1 — `mosaic uninstall` (IUH-M01)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ----- | ------ | --------------------- | ---------- | -------- | ------------------------------------------------------ |
|
||||
| IUH-01-01 | done | Design install manifest schema (`~/.config/mosaic/.install-manifest.json`) — what install writes on first success | #425 | sonnet | feat/mosaic-uninstall | — | 8K | v1 schema in `install-manifest.ts` |
|
||||
| IUH-01-02 | done | `mosaic uninstall` TS command: `--framework`, `--cli`, `--gateway`, `--all`, `--keep-data`, `--yes`, `--dry-run` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-01 | 25K | `uninstall.ts` |
|
||||
| IUH-01-03 | done | Reverse runtime asset linking in `~/.claude/` — restore `.mosaic-bak-*` if present, remove managed copies otherwise | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 12K | file list hardcoded from mosaic-link-runtime-assets |
|
||||
| IUH-01-04 | done | Reverse npmrc scope mapping and PATH edits made by `tools/install.sh` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 8K | npmrc reversed; no PATH edits found in v0.0.24 install |
|
||||
| IUH-01-05 | done | Shell fallback: `tools/install.sh --uninstall` path for users without a working CLI | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 10K | |
|
||||
| IUH-01-06 | done | Vitest coverage: dry-run output, `--all`, `--keep-data`, partial state, missing manifest | #425 | sonnet | feat/mosaic-uninstall | IUH-01-05 | 15K | 14 new tests, 170 total |
|
||||
| IUH-01-07 | done | Code review (independent) + remediation | #425 | sonnet | feat/mosaic-uninstall | IUH-01-06 | 5K | |
|
||||
| IUH-01-08 | done | PR open, CI green, review, merge to `main`, close issue | #425 | sonnet | feat/mosaic-uninstall | IUH-01-07 | 3K | PR #429, merge 25cada77 |
|
||||
|
||||
## Milestone 2 — Wizard Remediation (IUH-M02)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | -------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------- | ---------- | -------- | ----------------------------------------------- |
|
||||
| IUH-02-01 | done | Password masking: replace plaintext `rl.question` in `bootstrapFirstUser` with masked TTY read + confirmation | #426 | sonnet | feat/wizard-remediation | IUH-01-08 | 8K | `prompter/masked-prompt.ts` |
|
||||
| IUH-02-02 | done | Hooks preview stage in wizard: show `framework/runtime/claude/hooks-config.json` entries + confirm prompt | #426 | sonnet | feat/wizard-remediation | IUH-02-01 | 12K | `stages/hooks-preview.ts`; finalize gating TODO |
|
||||
| IUH-02-03 | done | `mosaic config hooks list\|enable\|disable` subcommands | #426 | sonnet | feat/wizard-remediation | IUH-02-02 | 15K | `commands/config.ts` |
|
||||
| IUH-02-04 | done | Headless path: env-var driven `runConfigWizard` + `bootstrapFirstUser` (`MOSAIC_ASSUME_YES`, `MOSAIC_ADMIN_*`) | #426 | sonnet | feat/wizard-remediation | IUH-02-03 | 12K | |
|
||||
| IUH-02-05 | done | Tests + code review + PR merge | #426 | sonnet | feat/wizard-remediation | IUH-02-04 | 10K | PR #431, merge cd8b1f66 |
|
||||
|
||||
## Milestone 3 — Unified First-Run Wizard (IUH-M03)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ----------------------------------------------------------------------------------------------------------- | ----- | ----- | ---------------------- | ---------- | -------- | ---------------------------------- |
|
||||
| IUH-03-01 | done | Design doc: unified state machine; decide whether `mosaic gateway install` becomes an internal wizard stage | #427 | opus | feat/unified-first-run | IUH-02-05 | 10K | scratchpad Session 5 |
|
||||
| IUH-03-02 | done | Refactor `runWizard` to invoke gateway install as a stage; drop the 10-minute session-file bridge | #427 | opus | feat/unified-first-run | IUH-03-01 | 25K | stages 11 & 12; bridge removed |
|
||||
| IUH-03-03 | done | Preserve backward-compat: `mosaic gateway install` still works as a standalone entry point | #427 | opus | feat/unified-first-run | IUH-03-02 | 10K | thin wrapper over stages |
|
||||
| IUH-03-04 | done | Tests + code review + PR merge | #427 | opus | feat/unified-first-run | IUH-03-03 | 12K | PR #433, merge 732f8a49; +15 tests |
|
||||
| IUH-03-05 | done | Bonus: honor `state.hooks.accepted` in finalize stage (closes M02 follow-up) | #427 | opus | feat/unified-first-run | IUH-03-04 | 5K | MOSAIC_SKIP_CLAUDE_HOOKS env flag |
|
||||
@@ -0,0 +1,74 @@
|
||||
# Mission Manifest — Install UX v2
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** install-ux-v2-20260405
|
||||
**Statement:** The install-ux-hardening mission shipped the plumbing (uninstall, masked password, hooks consent, unified flow, headless path), but the first real end-to-end run surfaced a critical regression and a collection of UX failings that make the wizard feel neither quick nor intelligent. This mission closes the bootstrap regression as a hotfix, then rethinks the first-run experience around a provider-first, intent-driven flow with a drill-down main menu and a genuinely fast quick-start.
|
||||
**Phase:** Closed
|
||||
**Current Milestone:** —
|
||||
**Progress:** 3 / 3 milestones
|
||||
**Status:** complete
|
||||
**Last Updated:** 2026-04-19 (archived during MVP manifest authoring; IUV-M03 substantively shipped via PR #446 — drill-down menu + provider-first flow + quick start; releases 0.0.27 → 0.0.29)
|
||||
**Archived to:** `docs/archive/missions/install-ux-v2-20260405/`
|
||||
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
|
||||
|
||||
## Context
|
||||
|
||||
Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
|
||||
|
||||
1. **Critical:** admin bootstrap fails with HTTP 400 `property email should not exist` — `bootstrap.controller.ts` uses `import type { BootstrapSetupDto }`, erasing the class at runtime. Nest's `@Body()` falls back to plain `Object` metatype, and ValidationPipe with `forbidNonWhitelisted` rejects every property. One-character fix (drop the `type` keyword), but it blocks the happy path of the release that just shipped.
|
||||
2. The wizard reports `✔ Wizard complete` and `✔ Done` _after_ the bootstrap 400 — failure only propagates in headless mode (`wizard.ts:147`).
|
||||
3. The gateway port prompt does not prefill `14242` in the input buffer.
|
||||
4. `"What is Mosaic?"` intro copy does not mention Pi SDK (the actual agent runtime behind Claude/Codex/OpenCode).
|
||||
5. CORS origin prompt is confusing — the user should be able to supply an FQDN/hostname and have the system derive the CORS value.
|
||||
6. Skill / additional feature install section is unusable in practice.
|
||||
7. Quick-start asks far too many questions to be meaningfully "quick".
|
||||
8. No drill-down main menu — everything is a linear interrogation.
|
||||
9. Provider setup happens late and without intelligence. An OpenClaw-style provider-first flow would let the user describe what they want in natural language, have the agent expound on it, and have the agent choose its own name based on that intent.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] AC-1: Admin bootstrap completes successfully end-to-end on a fresh install (DTO value import, no forbidNonWhitelisted regression); covered by an integration or e2e test that exercises the real DTO binding. _(PR #440)_
|
||||
- [x] AC-2: Wizard fails loudly (non-zero exit, clear error) when the bootstrap stage returns `completed: false`, in both interactive and headless modes. No more silent `✔ Wizard complete` after a 400. _(PR #440)_
|
||||
- [x] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept). _(PR #440)_
|
||||
- [x] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime. _(PR #440)_
|
||||
- [x] AC-5: Release `mosaic-v0.0.26` tagged and published to the Gitea npm registry, unblocking the 0.0.25 happy path. _(tag: mosaic-v0.0.26, registry: 0.0.26 live)_
|
||||
- [ ] AC-6: CORS origin prompt replaced with FQDN/hostname input; CORS string is derived from that.
|
||||
- [ ] AC-7: Skill / additional feature install section is reworked until it is actually usable end-to-end (worker defines the concrete failure modes during diagnosis).
|
||||
- [ ] AC-8: First-run flow has a drill-down main menu with at least `Plugins` (Recommended / Custom), `Providers`, and the other top-level configuration groups. Linear interrogation is gone.
|
||||
- [ ] AC-9: `Quick Start` path completes with a minimal, curated set of questions (target: under 90 seconds for a returning user; define the exact baseline during design).
|
||||
- [ ] AC-10: Provider setup happens first, driven by a natural-language intake prompt. The agent expounds on the user's intent and chooses its own name based on that intent (OpenClaw-style). Naming is confirmable / overridable.
|
||||
- [ ] AC-11: All milestones ship as merged PRs with green CI and closed issues.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | ------------------------------------------------------------ | -------- | ---------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | IUV-M01 | Hotfix: bootstrap DTO + wizard failure + port prefill + copy | complete | fix/bootstrap-hotfix | #436 | 2026-04-05 | 2026-04-05 |
|
||||
| 2 | IUV-M02 | UX polish: CORS/FQDN, skill installer rework | complete | feat/install-ux-polish | #437 | 2026-04-05 | 2026-04-05 |
|
||||
| 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | complete | feat/install-ux-intent | #438 | 2026-04-05 | 2026-04-19 |
|
||||
|
||||
## Subagent Delegation Plan
|
||||
|
||||
| Milestone | Recommended Tier | Rationale |
|
||||
| --------- | ---------------- | --------------------------------------------------------------------- |
|
||||
| IUV-M01 | sonnet | Tight bug cluster with known fix sites + small release cycle |
|
||||
| IUV-M02 | sonnet | UX rework, moderate surface, diagnostic-heavy for the skill installer |
|
||||
| IUV-M03 | opus | Architectural redesign of first-run flow, state machine + LLM intake |
|
||||
|
||||
## Risks
|
||||
|
||||
- **Hotfix regression surface** — the `import type` → `import` fix on the DTO class is one character but needs an integration test that binds the real DTO, not just a controller unit test, to prevent the same class-erasure regression from sneaking back in.
|
||||
- **LLM-driven intake latency / offline** — M03's provider-first intent flow assumes an available LLM call to expound on user input and choose a name. Offline installs need a deterministic fallback.
|
||||
- **Menu vs. linear back-compat** — M03 changes the top-level flow shape; existing `tools/install.sh --yes` + env-var headless path must continue to work.
|
||||
- **Scope creep in M03** — "redesign the wizard" can absorb arbitrary work. Keep it bounded with explicit non-goals.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Migrating the wizard to a GUI / web UI (still terminal-first)
|
||||
- Replacing the Gitea registry or the Woodpecker publish pipeline
|
||||
- Multi-tenant / multi-user onboarding (still single-admin bootstrap)
|
||||
- Reworking `mosaic uninstall` (M01 of the parent mission — stable)
|
||||
39
docs/archive/missions/install-ux-v2-20260405/TASKS.md
Normal file
39
docs/archive/missions/install-ux-v2-20260405/TASKS.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Tasks — Install UX v2
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** install-ux-v2-20260405
|
||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||
|
||||
## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | -------------------- | ---------- | -------- | --------------------------------------------------------------------------------------- |
|
||||
| IUV-01-01 | done | Fix `apps/gateway/src/admin/bootstrap.controller.ts:16` — switch `import type { BootstrapSetupDto }` to a value import so Nest's `@Body()` binds the real class | #436 | sonnet | fix/bootstrap-hotfix | — | 3K | PR #440 merged `0ae932ab` |
|
||||
| IUV-01-02 | done | Add integration / e2e test that POSTs `/api/bootstrap/setup` with `{name,email,password}` against a real Nest app instance and asserts 201 — NOT a mocked controller unit test | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-01 | 10K | `apps/gateway/src/admin/bootstrap.e2e.spec.ts` — 4 tests; unplugin-swc added for vitest |
|
||||
| IUV-01-03 | done | `packages/mosaic/src/wizard.ts:147` — propagate `!bootstrapResult.completed` as a wizard failure in **interactive** mode too (not only headless); non-zero exit + no `✔ Wizard complete` line | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-02 | 5K | removed `&& headlessRun` guard |
|
||||
| IUV-01-04 | done | Gateway port prompt prefills `14242` in the input buffer — investigate why `promptPort`'s `defaultValue` isn't reaching the user-visible input | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-03 | 5K | added `initialValue` through prompter interface → clack |
|
||||
| IUV-01-05 | done | `"What is Mosaic?"` intro copy updated to mention Pi SDK as the underlying agent runtime (alongside Claude Code / Codex / OpenCode) | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-04 | 2K | `packages/mosaic/src/stages/welcome.ts` |
|
||||
| IUV-01-06 | done | Tests + code review + PR merge + tag `mosaic-v0.0.26` + Gitea release + npm registry republish | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-05 | 10K | PRs #440/#441/#442 merged; tag `mosaic-v0.0.26`; registry latest=0.0.26 ✓ |
|
||||
|
||||
## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------- | ---------- | -------- | ---------------------------------------------------------------------- |
|
||||
| IUV-02-01 | done | Replace CORS origin prompt with FQDN / hostname input; derive the CORS value internally; default to `localhost` with clear help text | #437 | sonnet | feat/install-ux-polish | — | 10K | `deriveCorsOrigin()` pure fn; MOSAIC_HOSTNAME headless var; PR #444 |
|
||||
| IUV-02-02 | done | Diagnose and document the concrete failure modes of the current skill / additional feature install section end-to-end | #437 | sonnet | feat/install-ux-polish | IUV-02-01 | 8K | selection→install gap, silent catch{}, no whitelist concept |
|
||||
| IUV-02-03 | done | Rework the skill installer so it is usable end-to-end (selection, install, verify, failure reporting) | #437 | sonnet | feat/install-ux-polish | IUV-02-02 | 20K | MOSAIC_INSTALL_SKILLS env var whitelist; SyncSkillsResult typed return |
|
||||
| IUV-02-04 | done | Tests + code review + PR merge | #437 | sonnet | feat/install-ux-polish | IUV-02-03 | 10K | 18 new tests (13 CORS + 5 skills); PR #444 merged `172bacb3` |
|
||||
|
||||
## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03)
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----- | ---------------------- | ---------- | -------- | ------------------------------------------------------------- |
|
||||
| IUV-03-01 | not-started | Design doc: new first-run state machine — main menu (Plugins / Providers / …), Quick Start vs Custom paths, provider-first flow, intent intake + naming loop | #438 | opus | feat/install-ux-intent | — | 15K | scratchpad + explicit non-goals |
|
||||
| IUV-03-02 | not-started | Implement drill-down main menu (Plugins: Recommended / Custom, Providers, …) as the top-level entry point of `mosaic wizard` | #438 | opus | feat/install-ux-intent | IUV-03-01 | 25K | |
|
||||
| IUV-03-03 | not-started | Quick Start path: curated minimum question set — define the exact baseline, delete everything else from the fast path | #438 | opus | feat/install-ux-intent | IUV-03-02 | 15K | |
|
||||
| IUV-03-04 | not-started | Provider-first natural-language intake: user describes intent → agent expounds → agent proposes a name (confirmable / overridable) — OpenClaw-style | #438 | opus | feat/install-ux-intent | IUV-03-03 | 25K | offline fallback required (deterministic default name + path) |
|
||||
| IUV-03-05 | not-started | Preserve backward-compat: headless path (`MOSAIC_ASSUME_YES=1` + env vars) still works end-to-end; `tools/install.sh --yes` unchanged | #438 | opus | feat/install-ux-intent | IUV-03-04 | 10K | |
|
||||
| IUV-03-06 | not-started | Tests + code review + PR merge + `mosaic-v0.0.27` release | #438 | opus | feat/install-ux-intent | IUV-03-05 | 15K | |
|
||||
227
docs/archive/missions/install-ux-v2-20260405/iuv-m03-design.md
Normal file
227
docs/archive/missions/install-ux-v2-20260405/iuv-m03-design.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# IUV-M03 Design: Provider-first intelligent flow + drill-down main menu
|
||||
|
||||
**Issue:** #438
|
||||
**Branch:** `feat/install-ux-intent`
|
||||
**Date:** 2026-04-05
|
||||
|
||||
## 1. New first-run state machine
|
||||
|
||||
The linear 12-stage interrogation is replaced with a menu-driven architecture.
|
||||
|
||||
### Flow overview
|
||||
|
||||
```
|
||||
Welcome banner
|
||||
|
|
||||
v
|
||||
Detect existing install (auto)
|
||||
|
|
||||
v
|
||||
Main Menu (loop)
|
||||
|-- Quick Start -> provider key + admin creds -> finalize
|
||||
|-- Providers -> LLM API key config
|
||||
|-- Agent Identity -> intent intake + naming (deterministic)
|
||||
|-- Skills -> recommended / custom selection
|
||||
|-- Gateway -> port, storage tier, hostname, CORS
|
||||
|-- Advanced -> SOUL.md, USER.md, TOOLS.md, runtimes, hooks
|
||||
|-- Finish & Apply -> finalize + gateway bootstrap
|
||||
v
|
||||
Done
|
||||
```
|
||||
|
||||
### Menu navigation
|
||||
|
||||
- Main menu is a `select` prompt. Each option drills into a sub-flow.
|
||||
- Completing a section returns to the main menu.
|
||||
- Menu items show completion state: `[done]` hint after configuration.
|
||||
- `Finish & Apply` is always last and requires at minimum a provider key (or explicit skip).
|
||||
- The menu tracks configured sections in `WizardState.completedSections`.
|
||||
|
||||
### Headless bypass
|
||||
|
||||
When `MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`, the entire menu is skipped.
|
||||
The wizard runs: defaults + env var overrides -> finalize -> gateway config -> bootstrap.
|
||||
This preserves full backward compatibility with `tools/install.sh --yes`.
|
||||
|
||||
## 2. Quick Start path
|
||||
|
||||
Target: 3-5 questions max. Under 90 seconds for a returning user.
|
||||
|
||||
### Questions asked
|
||||
|
||||
1. **Provider API key** (Anthropic/OpenAI) - `text` prompt with paste support
|
||||
2. **Admin email** - `text` prompt
|
||||
3. **Admin password** - masked + confirmed
|
||||
|
||||
### Questions skipped (with defaults)
|
||||
|
||||
| Setting | Default | Rationale |
|
||||
| ---------------------------- | ------------------------------- | ---------------------- |
|
||||
| Agent name | "Mosaic" | Generic but branded |
|
||||
| Port | 14242 | Standard default |
|
||||
| Storage tier | local | No external deps |
|
||||
| Hostname | localhost | Dev-first |
|
||||
| CORS origin | http://localhost:3000 | Standard web UI port |
|
||||
| Skills | recommended set | Curated by maintainers |
|
||||
| Runtimes | auto-detected | No user input needed |
|
||||
| Communication style | direct | Most popular choice |
|
||||
| SOUL.md / USER.md / TOOLS.md | template defaults | Can customize later |
|
||||
| Hooks | auto-install if Claude detected | Safe default |
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
Quick Start selected
|
||||
-> "Paste your LLM API key (Anthropic recommended):"
|
||||
-> [auto-detect provider from key prefix: sk-ant-* = Anthropic, sk-* = OpenAI]
|
||||
-> Apply all defaults
|
||||
-> Run finalize (sync framework, write configs, link assets, sync skills)
|
||||
-> Run gateway config (headless-style with defaults + provided key)
|
||||
-> "Admin email:"
|
||||
-> "Admin password:" (masked + confirm)
|
||||
-> Run gateway bootstrap
|
||||
-> Done
|
||||
```
|
||||
|
||||
## 3. Provider-first flow
|
||||
|
||||
Provider configuration (currently buried in gateway-config stage as "ANTHROPIC_API_KEY")
|
||||
moves to a dedicated top-level menu item and is the first question in Quick Start.
|
||||
|
||||
### Provider detection
|
||||
|
||||
The API key prefix determines the provider:
|
||||
|
||||
- `sk-ant-api03-*` -> Anthropic (Claude)
|
||||
- `sk-*` -> OpenAI
|
||||
- Empty/skipped -> no provider (gateway starts without LLM access)
|
||||
|
||||
### Storage
|
||||
|
||||
The provider key is stored in the gateway `.env` as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`.
|
||||
For Quick Start, this replaces the old interactive prompt in `collectAndWriteConfig`.
|
||||
|
||||
### Menu section: "Providers"
|
||||
|
||||
In the drill-down menu, "Providers" lets users:
|
||||
|
||||
1. Enter/change their API key
|
||||
2. See which provider was detected
|
||||
3. Optionally configure a second provider
|
||||
|
||||
For v0.0.27, we support Anthropic and OpenAI keys only. The key is stored
|
||||
in `WizardState` and written during finalize.
|
||||
|
||||
## 4. Intent intake + naming (deterministic fallback - Option B)
|
||||
|
||||
### Rationale
|
||||
|
||||
At install time, the LLM provider may not be configured yet (chicken-and-egg).
|
||||
We use **Option B: deterministic advisor** for the install wizard.
|
||||
|
||||
### Flow (Agent Identity menu section)
|
||||
|
||||
```
|
||||
1. "What will this agent primarily help you with?"
|
||||
-> Select from presets:
|
||||
- General purpose assistant
|
||||
- Software development
|
||||
- DevOps & infrastructure
|
||||
- Research & analysis
|
||||
- Content & writing
|
||||
- Custom (free text description)
|
||||
|
||||
2. System proposes a thematic name based on selection:
|
||||
- General purpose -> "Mosaic"
|
||||
- Software development -> "Forge"
|
||||
- DevOps & infrastructure -> "Sentinel"
|
||||
- Research & analysis -> "Atlas"
|
||||
- Content & writing -> "Muse"
|
||||
- Custom -> "Mosaic" (default)
|
||||
|
||||
3. "Your agent will be named 'Forge'. Press Enter to accept or type a new name:"
|
||||
-> User confirms or overrides
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
- Agent name -> `WizardState.soul.agentName` -> written to SOUL.md
|
||||
- Intent category -> `WizardState.agentIntent` (new field) -> written to `~/.config/mosaic/agent.json`
|
||||
|
||||
### Post-install LLM-powered intake (future)
|
||||
|
||||
A future `mosaic configure identity` command can use the configured LLM to:
|
||||
|
||||
- Accept free-text intent description
|
||||
- Generate an expounded persona
|
||||
- Propose a contextual name
|
||||
|
||||
This is explicitly out of scope for the install wizard.
|
||||
|
||||
## 5. Headless backward-compat
|
||||
|
||||
### Supported env vars (unchanged)
|
||||
|
||||
| Variable | Used by |
|
||||
| -------------------------- | ---------------------------------------------- |
|
||||
| `MOSAIC_ASSUME_YES=1` | Skip all prompts, use defaults + env overrides |
|
||||
| `MOSAIC_ADMIN_NAME` | Gateway bootstrap |
|
||||
| `MOSAIC_ADMIN_EMAIL` | Gateway bootstrap |
|
||||
| `MOSAIC_ADMIN_PASSWORD` | Gateway bootstrap |
|
||||
| `MOSAIC_GATEWAY_PORT` | Gateway config |
|
||||
| `MOSAIC_HOSTNAME` | Gateway config (CORS derivation) |
|
||||
| `MOSAIC_CORS_ORIGIN` | Gateway config (full override) |
|
||||
| `MOSAIC_STORAGE_TIER` | Gateway config (local/team) |
|
||||
| `MOSAIC_DATABASE_URL` | Gateway config (team tier) |
|
||||
| `MOSAIC_VALKEY_URL` | Gateway config (team tier) |
|
||||
| `MOSAIC_ANTHROPIC_API_KEY` | Provider config |
|
||||
|
||||
### New env vars
|
||||
|
||||
| Variable | Purpose |
|
||||
| --------------------- | ----------------------------------------- |
|
||||
| `MOSAIC_AGENT_NAME` | Override agent name in headless mode |
|
||||
| `MOSAIC_AGENT_INTENT` | Override intent category in headless mode |
|
||||
|
||||
### `tools/install.sh --yes`
|
||||
|
||||
The install script sets `MOSAIC_ASSUME_YES=1` and passes through env vars.
|
||||
No changes needed to the script itself. The new wizard detects headless mode
|
||||
at the top of `runWizard` and runs a linear path identical to the old flow.
|
||||
|
||||
## 6. Explicit non-goals
|
||||
|
||||
- **No GUI** — this is a terminal wizard only
|
||||
- **No multi-user install** — single-user, single-machine
|
||||
- **No registry changes** — npm publish flow is unchanged
|
||||
- **No LLM calls during install** — deterministic fallback only
|
||||
- **No new dependencies** — uses existing @clack/prompts and picocolors
|
||||
- **No changes to gateway API** — only the wizard orchestration changes
|
||||
- **No changes to tools/install.sh** — headless compat maintained via env vars
|
||||
|
||||
## 7. Implementation plan
|
||||
|
||||
### Files to modify
|
||||
|
||||
1. `packages/mosaic/src/types.ts` — add `MenuSection`, `AgentIntent`, `completedSections`, `agentIntent`, `providerKey`, `providerType` to WizardState
|
||||
2. `packages/mosaic/src/wizard.ts` — replace linear flow with menu loop
|
||||
3. `packages/mosaic/src/stages/mode-select.ts` — becomes the main menu
|
||||
4. `packages/mosaic/src/stages/provider-setup.ts` — new: provider key collection
|
||||
5. `packages/mosaic/src/stages/agent-intent.ts` — new: intent intake + naming
|
||||
6. `packages/mosaic/src/stages/menu-gateway.ts` — new: gateway sub-menu wrapper
|
||||
7. `packages/mosaic/src/stages/quick-start.ts` — new: quick start linear path
|
||||
8. `packages/mosaic/src/constants.ts` — add intent presets and name mappings
|
||||
9. `packages/mosaic/package.json` — version bump 0.0.26 -> 0.0.27
|
||||
|
||||
### Files to add (tests)
|
||||
|
||||
1. `packages/mosaic/src/stages/wizard-menu.spec.ts` — menu navigation tests
|
||||
2. `packages/mosaic/src/stages/quick-start.spec.ts` — quick start path tests
|
||||
3. `packages/mosaic/src/stages/agent-intent.spec.ts` — intent + naming tests
|
||||
4. `packages/mosaic/src/stages/provider-setup.spec.ts` — provider detection tests
|
||||
|
||||
### Migration strategy
|
||||
|
||||
The existing stage functions remain intact. The menu system wraps them —
|
||||
each menu item calls the appropriate stage function(s). The linear headless
|
||||
path calls them in the same order as before.
|
||||
173
docs/archive/missions/install-ux-v2-20260405/scratchpad.md
Normal file
173
docs/archive/missions/install-ux-v2-20260405/scratchpad.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Install UX v2 — Orchestrator Scratchpad
|
||||
|
||||
## Session 1 — 2026-04-05 (orchestrator scaffold)
|
||||
|
||||
### Trigger
|
||||
|
||||
Real-run testing of `@mosaicstack/mosaic@0.0.25` (fresh install of the release we just shipped from the parent mission `install-ux-hardening-20260405`) surfaced a critical regression and a cluster of UX failings. User feedback verbatim:
|
||||
|
||||
> The skill/additional feature installation section of install.sh is unsable
|
||||
> The "quick-start" is asking way too many questions. This process should be much faster to get a quick start.
|
||||
> The installater should have a main menu that allows for a drill-down install approach.
|
||||
> "Plugins" — Install Recommended Plugins / Custom
|
||||
> "Providers" — …
|
||||
> The gateway port is not prefilling with 14242 for default
|
||||
> What is the CORS origin for? Is that the webUI that isn't working yet? Maybe we should ask for the fqdn/hostname instead? There must be a better way to handle this.
|
||||
|
||||
Plus the critical bug, reproduced verbatim:
|
||||
|
||||
```
|
||||
◇ Admin email
|
||||
│ jason@woltje.com
|
||||
Admin password (min 8 chars): ****************
|
||||
Confirm password: ****************
|
||||
│
|
||||
▲ Bootstrap failed (400): {"message":["property email should not exist","property password should not exist"],"error":"Bad Request","statusCode":400}
|
||||
✔ Wizard complete.
|
||||
✔ Install manifest written: /home/jarvis/.config/mosaic/.install-manifest.json
|
||||
|
||||
✔ Done.
|
||||
```
|
||||
|
||||
Note the `✔ Wizard complete` and `✔ Done` lines **after** the 400. That's a second bug — failure didn't propagate in interactive mode.
|
||||
|
||||
### Diagnosis — orchestrator pre-scope
|
||||
|
||||
To avoid handing workers a vague prompt, pre-identified the concrete fix sites:
|
||||
|
||||
**Bug 1 (critical) — DTO class erasure.** `apps/gateway/src/admin/bootstrap.controller.ts:16`:
|
||||
|
||||
```ts
|
||||
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||
```
|
||||
|
||||
`import type` erases the class at runtime. `@Body() dto: BootstrapSetupDto` then has no runtime metatype — `design:paramtypes` reflects `Object`. Nest's `ValidationPipe` with `whitelist: true` + `forbidNonWhitelisted: true` receives a plain Object metatype, treats every incoming property as non-whitelisted, and 400s with `"property email should not exist", "property password should not exist"`.
|
||||
|
||||
**One-character fix:** drop the `type` keyword on the `BootstrapSetupDto` import. `BootstrapStatusDto` and `BootstrapResultDto` are fine as type-only imports because they're used only in return type positions, not as `@Body()` metatypes.
|
||||
|
||||
Must be covered by an **integration test that binds through Nest**, not a controller unit test that imports the DTO directly — the unit test path would pass even with `import type` because it constructs the pipe manually. An e2e test with `@nestjs/testing` + `supertest` against the real `/api/bootstrap/setup` endpoint is the right guard.
|
||||
|
||||
**Bug 2 — interactive silent failure.** `packages/mosaic/src/wizard.ts:147-150`:
|
||||
|
||||
```ts
|
||||
if (!bootstrapResult.completed && headlessRun) {
|
||||
prompter.warn('Admin bootstrap failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
The guard is `&& headlessRun`. In interactive mode, `completed: false` is silently swallowed and the wizard continues to the success lines. Fix: propagate failure in both modes. Decision for the worker — either `throw` or `process.exit(1)` with a clear error.
|
||||
|
||||
**Bug 3 — port prefill.** `packages/mosaic/src/stages/gateway-config.ts:77-88`:
|
||||
|
||||
```ts
|
||||
const raw = await p.text({
|
||||
message: 'Gateway port',
|
||||
defaultValue: defaultPort.toString(),
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
The stage is passing `defaultValue`. Either the `WizardPrompter.text` adapter is dropping it, or the underlying `@clack/prompts` call expects `initialValue` (which actually prefills the buffer) vs `defaultValue` (which is used only if the user submits an empty string). Worker should verify the adapter and likely switch to `initialValue` semantics so the user sees `14242` in the field.
|
||||
|
||||
**Bug 4 — Pi SDK copy gap.** The `"What is Mosaic?"` intro text enumerates Claude Code, Codex, and OpenCode but never mentions Pi SDK, which is the actual agent runtime behind those frontends. Purely a copy edit — find the string, add Pi SDK.
|
||||
|
||||
### Mission shape
|
||||
|
||||
Three milestones, three tracks, different tiers:
|
||||
|
||||
1. **IUV-M01 Hotfix** (sonnet) — the four bugs above + release `mosaic-v0.0.26`. Small, fast, unblocks the 0.0.25 happy path.
|
||||
2. **IUV-M02 UX polish** (sonnet) — CORS origin → FQDN/hostname abstraction; diagnose and rework the skill installer section. Diagnostic-heavy.
|
||||
3. **IUV-M03 Provider-first intelligent flow** (opus) — the big one: drill-down main menu, Quick Start path that's actually quick, provider-first natural-language intake with agent self-naming (OpenClaw-style). Architectural.
|
||||
|
||||
Sequencing: strict. M01 ships first as a hotfix release (mosaic-v0.0.26). M02 is diagnostic-heavy and can share groundwork with M03 but ships separately for clean release notes. M03 is the architectural anchor and lands last as `mosaic-v0.0.27`.
|
||||
|
||||
### Open design questions (to be resolved by workers, not pre-decided)
|
||||
|
||||
- M01: does `process.exit(1)` vs `throw` matter for how `tools/install.sh` surfaces the error? Worker should check the install.sh call site and pick the behavior that surfaces cleanly.
|
||||
- M03: what LLM call powers the intent intake, and what's the offline fallback? Options: (a) reuse the provider the user is configuring (chicken-and-egg — provider setup hasn't happened yet), (b) a bundled deterministic "advisor" that hard-codes common intents, (c) require a provider key up-front before intake. Design doc (IUV-03-01) must resolve.
|
||||
- M03: is the "agent self-naming" persistent across all future `mosaic` invocations, or a per-session nickname? Probably persistent — lives in `~/.config/mosaic/agent.json` or similar. Worker to decide + document.
|
||||
|
||||
### Non-goals for this mission
|
||||
|
||||
- No GUI / web UI
|
||||
- No registry / pipeline migration
|
||||
- No multi-user / multi-tenant onboarding
|
||||
- No rework of `mosaic uninstall` (stable from parent mission)
|
||||
|
||||
### Known tooling caveats (carry forward from parent mission)
|
||||
|
||||
- `issue-create.sh` / `pr-create.sh` wrappers have an `eval` bug with multiline bodies — use Gitea REST API fallback with `load_credentials gitea-mosaicstack`
|
||||
- `pr-ci-wait.sh` reports `state=unknown` against Woodpecker (combined-status endpoint gap) — use `tea pr` glyphs or poll the commit status endpoint directly
|
||||
- Protected `main`, squash-merge only, PR-required
|
||||
- CI queue guard before push/merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge`
|
||||
|
||||
### Next action
|
||||
|
||||
1. Create Gitea issues for M01, M02, M03
|
||||
2. Open the mission-scaffold docs PR (same pattern as parent mission's PR #430)
|
||||
3. After merge, delegate IUV-M01 to a sonnet subagent in an isolated worktree with the concrete fix-site pointers above
|
||||
|
||||
## Session 2 — 2026-04-05 (IUV-M01 delivery + close-out)
|
||||
|
||||
### Outcome
|
||||
|
||||
IUV-M01 shipped. `mosaic-v0.0.26` released and registry latest confirmed `0.0.26`.
|
||||
|
||||
### PRs merged
|
||||
|
||||
| PR | Title | Merge |
|
||||
| ---- | ------------------------------------------------------------------------ | -------- |
|
||||
| #440 | fix: bootstrap hotfix — DTO erasure, wizard failure, port prefill, copy | 0ae932ab |
|
||||
| #441 | fix: add vitest.config.ts to eslint allowDefaultProject (#440 build fix) | c08aa6fa |
|
||||
| #442 | docs: mark IUV-M01 complete — mosaic-v0.0.26 released | 78388437 |
|
||||
|
||||
### Bugs fixed (all 4 in worker's PR #440)
|
||||
|
||||
1. **DTO class erasure** — `apps/gateway/src/admin/bootstrap.controller.ts:16` — dropped `type` from `import { BootstrapSetupDto }`. Guarded by new e2e test `bootstrap.e2e.spec.ts` (4 cases) that binds through a real Nest app with `ValidationPipe { whitelist, forbidNonWhitelisted }`. Test suite needed `unplugin-swc` in `apps/gateway/vitest.config.ts` to emit `decoratorMetadata` (tsx/esbuild can't).
|
||||
2. **Wizard silent failure** — `packages/mosaic/src/wizard.ts` — removed the `&& headlessRun` guard so `!bootstrapResult.completed` now aborts in both modes.
|
||||
3. **Port prefill** — root cause was clack's `defaultValue` vs `initialValue` semantics (`defaultValue` only fills on empty submit, `initialValue` prefills the buffer). Added an `initialValue` field to `WizardPrompter.text()` interface, threaded through clack and headless prompters, switched `gateway-config.ts` port/url prompts to use it.
|
||||
4. **Pi SDK copy** — `packages/mosaic/src/stages/welcome.ts` — intro copy now lists Pi SDK.
|
||||
|
||||
### Mid-delivery hiccup — tsconfig/eslint cross-contamination
|
||||
|
||||
Worker's initial approach added `vitest.config.ts` to `apps/gateway/tsconfig.json`'s `include` to appease the eslint parser. That broke `pnpm --filter @mosaicstack/gateway build` with TS6059 (`vitest.config.ts` outside `rootDir: "src"`). The publish pipeline on the `#440` merge commit failed.
|
||||
|
||||
**Correct fix** (worker's PR #441): leave `tsconfig.json` clean (`include: ["src/**/*"]`) and instead add the file to `allowDefaultProject` in the root `eslint.config.mjs`. This keeps the tsc program strict while letting eslint resolve a parser project for the standalone config file.
|
||||
|
||||
**Pattern to remember**: when adding root-level `.ts` config files (vitest, build scripts) to a package with `rootDir: "src"`, the eslint parser project conflict is solved with `allowDefaultProject`, NEVER by widening tsconfig include. I had independently arrived at the same fix on a branch before the worker shipped #441 — deleted the duplicate.
|
||||
|
||||
### Residual follow-ups carried forward
|
||||
|
||||
1. Headless prompter fallback order: worker set `initialValue > defaultValue` in the headless path. Correct semantic, but any future headless test that explicitly depends on `defaultValue` precedence will need review.
|
||||
2. Vitest + SWC decorator metadata pattern is now the blessed approach for NestJS e2e tests in this monorepo. Any other package that adds NestJS e2e tests should mirror `apps/gateway/vitest.config.ts`.
|
||||
|
||||
### Next action
|
||||
|
||||
- Close out orchestrator doc sync (this commit): mark M01 subtasks done in `TASKS.md`, update manifest phase to Execution, commit scratchpad session 2, PR to main.
|
||||
- After merge, delegate IUV-M02 (sonnet, isolated worktree). Dependencies: IUV-02-01 (CORS→FQDN) starts unblocked since M01 is released; first real task for the M02 worker is diagnosing the skill installer failure modes (IUV-02-02) against the fresh 0.0.26 install.
|
||||
|
||||
## Session 3 — 2026-04-05 (IUV-M02 delivery + close-out)
|
||||
|
||||
### Outcome
|
||||
|
||||
IUV-M02 shipped. PR #444 merged (`172bacb3`), issue #437 closed. 18 new tests (13 CORS derivation, 5 skill sync).
|
||||
|
||||
### Changes
|
||||
|
||||
**CORS → FQDN (IUV-02-01):**
|
||||
|
||||
- `packages/mosaic/src/stages/gateway-config.ts` — replaced raw "CORS origin" text prompt with "Web UI hostname" (default: `localhost`). Added HTTPS follow-up for remote hosts. Pure `deriveCorsOrigin(hostname, port, useHttps?)` function exported for testability.
|
||||
- Headless: `MOSAIC_HOSTNAME` env var as friendly alternative; `MOSAIC_CORS_ORIGIN` still works as full override.
|
||||
- `packages/mosaic/src/types.ts` — added `hostname?: string` to `GatewayState`.
|
||||
|
||||
**Skill installer rework (IUV-02-02 + IUV-02-03):**
|
||||
|
||||
- Root cause confirmed: `syncSkills()` in `finalize.ts` ignored `state.selectedSkills` entirely. The multiselect UI was a no-op.
|
||||
- `packages/mosaic/src/stages/finalize.ts` — `syncSkills()` rewritten to accept `selectedSkills[]`, returns typed `SyncSkillsResult`, passes `MOSAIC_INSTALL_SKILLS` (colon-separated) as env var to the bash script.
|
||||
- `packages/mosaic/framework/tools/_scripts/mosaic-sync-skills` — added bash associative array whitelist filter keyed on `MOSAIC_INSTALL_SKILLS`. When set, only whitelisted skills are linked. Empty/unset = all skills (legacy behavior preserved for `mosaic sync` outside wizard).
|
||||
- Failure surfaces: silent `catch {}` replaced with typed error reporting through `p.warn()`.
|
||||
|
||||
### Next action
|
||||
|
||||
- Delegate IUV-M03 (opus, isolated worktree) — the architectural milestone: provider-first intelligent flow, drill-down main menu, Quick Start fast path, agent self-naming. This is the biggest piece of the mission.
|
||||
30
docs/archive/missions/storage-abstraction/TASKS.md
Normal file
30
docs/archive/missions/storage-abstraction/TASKS.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Tasks — Storage Abstraction Retrofit
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
>
|
||||
> **Mission:** Decouple gateway from hardcoded Postgres/Valkey backends. Introduce interface-driven middleware so the gateway is backend-agnostic. Default to local tier (SQLite + JSON) for zero-dependency installs.
|
||||
>
|
||||
> **`agent` column values:** `codex` | `sonnet` | `haiku` | `glm-5` | `opus` | `—` (auto/default)
|
||||
|
||||
| id | status | agent | description | tokens |
|
||||
| --------- | ----------- | ------ | ---------------------------------------------------------------- | ------ |
|
||||
| SA-P1-001 | done | sonnet | Define QueueAdapter interface in packages/queue/src/types.ts | 3K |
|
||||
| SA-P1-002 | done | sonnet | Define StorageAdapter interface in packages/storage/src/types.ts | 3K |
|
||||
| SA-P1-003 | done | sonnet | Define MemoryAdapter interface in packages/memory/src/types.ts | 3K |
|
||||
| SA-P1-004 | done | sonnet | Create adapter factory pattern + config types | 3K |
|
||||
| SA-P2-001 | done | sonnet | Refactor @mosaicstack/queue: wrap ioredis as BullMQ adapter | 3K |
|
||||
| SA-P2-002 | done | sonnet | Create @mosaicstack/storage: wrap Drizzle as Postgres adapter | 6K |
|
||||
| SA-P2-003 | done | sonnet | Refactor @mosaicstack/memory: extract pgvector adapter | 4K |
|
||||
| SA-P2-004 | done | sonnet | Update gateway modules to use factories + DI tokens | 5K |
|
||||
| SA-P2-005 | done | opus | Verify Phase 2: all tests pass, typecheck clean | — |
|
||||
| SA-P3-001 | done | sonnet | Implement local queue adapter: JSON file persistence | 5K |
|
||||
| SA-P3-002 | done | sonnet | Implement SQLite storage adapter with better-sqlite3 | 8K |
|
||||
| SA-P3-003 | done | sonnet | Implement keyword memory adapter — no vector dependency | 4K |
|
||||
| SA-P3-004 | done | opus | Verify Phase 3: 42 new tests, 347 total passing | — |
|
||||
| SA-P4-001 | done | sonnet | MosaicConfig schema + loader with tier auto-detection | 6K |
|
||||
| SA-P4-002 | done | sonnet | CLI: mosaic gateway init — interactive wizard | 4K |
|
||||
| SA-P4-003 | done | sonnet | CLI: mosaic gateway start/stop/status lifecycle | 5K |
|
||||
| SA-P4-004 | done | opus | Verify Phase 4: 381 tests passing, 40/40 tasks clean | — |
|
||||
| SA-P5-001 | not-started | codex | Migration tooling: mosaic storage export/import | — |
|
||||
| SA-P5-002 | not-started | codex | Docker Compose profiles: local vs team | — |
|
||||
| SA-P5-003 | not-started | codex | Final verification + docs: README, architecture diagram | — |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user