Compare commits
218 Commits
mosaic-v0.
...
docs/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c3f28ae5a | ||
| 49e8a54105 | |||
| d077183554 | |||
| 405984af5a | |||
| 8dd4e9d541 | |||
| bc8016c831 | |||
| e72388b2cb | |||
| 6345dbfcf2 | |||
| c6e3cfbd95 | |||
| 5789711ee0 | |||
| f40e6ba388 | |||
| b7b0f508e6 | |||
| 3378b857eb | |||
| e2376190e5 | |||
| cca6aaf947 | |||
| 2363f155b4 | |||
| 76325ca3f2 | |||
| 9e5b9188ce | |||
| f1c6b37b46 | |||
| 0b621660c8 | |||
| 84d884b932 | |||
| 8246ee0137 | |||
| 99a2d0fc9d | |||
| e3b5113be2 | |||
| 24b07d0f83 | |||
| 86a50138a9 | |||
| 7b9f40d3b7 | |||
| 9a8a572fcf | |||
| 753a360517 | |||
| e92186d768 | |||
| 119f64e69d | |||
| 46ca3ce742 | |||
| 227b73fcdf | |||
| a959b1d6b4 | |||
| 62f8177806 | |||
| 353e43c947 | |||
| ca9c2b5c23 | |||
| b580d37d51 | |||
| 59e49cfd15 | |||
| a99aded26d | |||
| 4df38f7e81 | |||
| 4e9e053800 | |||
| 851c67c27b | |||
| 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 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -9,3 +9,16 @@ coverage
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
infra/step-ca/dev-password
|
||||||
|
|
||||||
|
# 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/
|
@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
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ pnpm-lock.yaml
|
|||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
.claude/
|
.claude/
|
||||||
|
docs/tess/TASKS.md
|
||||||
|
docs/scratchpads/
|
||||||
|
|||||||
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,9 +1,20 @@
|
|||||||
|
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
|
||||||
|
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
|
||||||
|
# store. The install step resolves from the baked store (--prefer-offline)
|
||||||
|
# instead of paying a ~731s cold fetch + native compile every run.
|
||||||
variables:
|
variables:
|
||||||
- &node_image 'node:22-alpine'
|
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- event: [push, pull_request, manual]
|
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
||||||
|
# push CI is restricted to protected branches (main) so a feature-branch push no
|
||||||
|
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
||||||
|
# CI load on the storage-constrained runner with zero loss of gating (branch
|
||||||
|
# protection requires no push/ci status context; main still gets full push CI).
|
||||||
|
- event: [pull_request, manual]
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
|
||||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||||
@@ -15,8 +26,21 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- corepack enable
|
||||||
- apk add --no-cache python3 make g++
|
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
|
||||||
- pnpm install --frozen-lockfile
|
# 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:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -25,6 +49,7 @@ steps:
|
|||||||
- pnpm typecheck
|
- pnpm typecheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
- sanitization
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
lint:
|
lint:
|
||||||
@@ -46,18 +71,27 @@ steps:
|
|||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
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:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# Install postgresql-client for pg_isready
|
# postgresql-client (pg_isready) is baked into ci-base.
|
||||||
- apk add --no-cache postgresql-client
|
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||||
# Wait up to 30s for postgres to be ready
|
|
||||||
- |
|
- |
|
||||||
for i in $(seq 1 30); do
|
ready=0
|
||||||
pg_isready -h postgres -p 5432 -U mosaic && break
|
for i in $(seq 1 60); do
|
||||||
echo "Waiting for postgres ($i/30)..."
|
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
|
||||||
|
ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for ci-postgres ($i/60)..."
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
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)
|
# Run migrations (DATABASE_URL is set in environment above)
|
||||||
- pnpm --filter @mosaicstack/db run db:migrate
|
- pnpm --filter @mosaicstack/db run db:migrate
|
||||||
# Run all tests
|
# Run all tests
|
||||||
@@ -66,7 +100,7 @@ steps:
|
|||||||
- typecheck
|
- typecheck
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
ci-postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: mosaic
|
POSTGRES_USER: mosaic
|
||||||
|
|||||||
@@ -2,8 +2,27 @@
|
|||||||
# Runs only on main branch push/tag
|
# Runs only on main branch push/tag
|
||||||
|
|
||||||
variables:
|
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'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
||||||
|
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
||||||
|
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
|
||||||
|
# (tags) always build everything. Exclude-list keeps the default SAFE: any
|
||||||
|
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||||
|
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||||
|
# the separate `event: tag` entry.)
|
||||||
|
- &image_build_when
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude:
|
||||||
|
- 'packages/mosaic/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- '.woodpecker/**'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
@@ -14,7 +33,8 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- 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:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -26,6 +46,15 @@ steps:
|
|||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
image: *node_image
|
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:
|
environment:
|
||||||
NPM_TOKEN:
|
NPM_TOKEN:
|
||||||
from_secret: gitea_token
|
from_secret: gitea_token
|
||||||
@@ -91,6 +120,7 @@ steps:
|
|||||||
|
|
||||||
build-gateway:
|
build-gateway:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -103,19 +133,20 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
build-web:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -128,12 +159,38 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/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
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
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: *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
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
16
AGENTS.md
16
AGENTS.md
@@ -58,14 +58,14 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
|||||||
|
|
||||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
||||||
|
|
||||||
| Value | When to use | Budget |
|
| Value | When to use | Budget |
|
||||||
| -------- | ----------------------------------------------------------- | -------------------------- |
|
| --------- | ----------------------------------------------------------- | -------------------------- |
|
||||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
||||||
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||||
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
||||||
|
|
||||||
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
26
README.md
26
README.md
@@ -7,7 +7,13 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
|||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/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:
|
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
||||||
@@ -52,6 +58,8 @@ mosaic yolo pi # Pi in yolo mode
|
|||||||
|
|
||||||
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
|
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
|
### TUI & Gateway
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -74,6 +82,8 @@ If you already have a gateway account but no token, use `mosaic gateway config r
|
|||||||
|
|
||||||
### Configuration
|
### 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
|
```bash
|
||||||
mosaic config show # Print full config as JSON
|
mosaic config show # Print full config as JSON
|
||||||
mosaic config get <key> # Read a specific key
|
mosaic config get <key> # Read a specific key
|
||||||
@@ -179,8 +189,8 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.mosaicstack.dev:mosaicstack/mosaic-stack.git
|
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
|
||||||
cd mosaic-stack
|
cd stack
|
||||||
|
|
||||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -229,7 +239,7 @@ npm packages are published to the Gitea package registry on main merges.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
mosaic-stack/
|
stack/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
||||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
||||||
@@ -302,7 +312,13 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
|||||||
Run the installer again — it handles upgrades automatically:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the direct URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the CLI:
|
Or use the CLI:
|
||||||
|
|||||||
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' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/appservice/tsconfig.json
Normal file
9
apps/appservice/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "apps/gateway"
|
"directory": "apps/gateway"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"@mariozechner/pi-ai": "^0.65.0",
|
"@mariozechner/pi-ai": "^0.65.0",
|
||||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
|
"@mosaicstack/agent": "workspace:^",
|
||||||
"@mosaicstack/auth": "workspace:^",
|
"@mosaicstack/auth": "workspace:^",
|
||||||
"@mosaicstack/brain": "workspace:^",
|
"@mosaicstack/brain": "workspace:^",
|
||||||
"@mosaicstack/config": "workspace:^",
|
"@mosaicstack/config": "workspace:^",
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
"@opentelemetry/sdk-metrics": "^2.6.0",
|
"@opentelemetry/sdk-metrics": "^2.6.0",
|
||||||
"@opentelemetry/sdk-node": "^0.213.0",
|
"@opentelemetry/sdk-node": "^0.213.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
|
"@peculiar/x509": "^2.0.0",
|
||||||
"@sinclair/typebox": "^0.34.48",
|
"@sinclair/typebox": "^0.34.48",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"bullmq": "^5.71.0",
|
"bullmq": "^5.71.0",
|
||||||
@@ -63,12 +65,16 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
|
"ioredis": "^5.10.0",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"openai": "^6.32.0",
|
"openai": "^6.32.0",
|
||||||
|
"postgres": "^3.4.8",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
"uuid": "^11.0.0",
|
"uuid": "^11.0.0",
|
||||||
|
"undici": "^7.24.6",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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,192 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
|
||||||
|
import {
|
||||||
|
createDiscordIngressEnvelope,
|
||||||
|
DiscordPlugin,
|
||||||
|
type DiscordIngressPayload,
|
||||||
|
} from '@mosaicstack/discord-plugin';
|
||||||
|
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||||
|
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
||||||
|
import { DurableSessionService } from '../../agent/durable-session.service.js';
|
||||||
|
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||||
|
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
||||||
|
|
||||||
|
const SERVICE_TOKEN = 'test-discord-service-token';
|
||||||
|
const envKeys = [
|
||||||
|
'DISCORD_SERVICE_TOKEN',
|
||||||
|
'DISCORD_SERVICE_TENANT_ID',
|
||||||
|
'DISCORD_INTERACTION_BINDINGS',
|
||||||
|
'DISCORD_ALLOWED_GUILD_IDS',
|
||||||
|
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||||
|
'DISCORD_ALLOWED_USER_IDS',
|
||||||
|
'MOSAIC_AGENT_NAME',
|
||||||
|
] as const;
|
||||||
|
const priorEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
messageId,
|
||||||
|
correlationId,
|
||||||
|
guildId: 'guild-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
userId: 'discord-admin-1',
|
||||||
|
conversationId: 'Nova:discord:channel-1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorization(): CommandAuthorizationService {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
return new CommandAuthorizationService(
|
||||||
|
{
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
get: async (key: string) => entries.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => entries.set(key, value),
|
||||||
|
del: async (key: string) => Number(entries.delete(key)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('interaction Discord/CLI durable-session integration', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of envKeys) {
|
||||||
|
const value = priorEnv.get(key);
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
priorEnv.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
|
||||||
|
for (const key of envKeys) priorEnv.set(key, process.env[key]);
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
||||||
|
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
|
||||||
|
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
|
||||||
|
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
pairedUsers: {
|
||||||
|
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const durable = new DurableSessionService(
|
||||||
|
new InMemoryDurableSessionStore() as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const enrollmentRuntime = {
|
||||||
|
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
||||||
|
};
|
||||||
|
const controller = new InteractionController(enrollmentRuntime as never, durable);
|
||||||
|
await controller.enroll(
|
||||||
|
'Nova',
|
||||||
|
'Nova:discord:channel-1',
|
||||||
|
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
|
||||||
|
'cli-enrollment-correlation',
|
||||||
|
);
|
||||||
|
|
||||||
|
const authz = authorization();
|
||||||
|
const terminated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const runtime = new RuntimeProviderService(
|
||||||
|
{
|
||||||
|
require: () => ({
|
||||||
|
capabilities: async () => ({ supported: ['session.terminate'] }),
|
||||||
|
terminate: terminated,
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{ record: async () => undefined } as never,
|
||||||
|
{
|
||||||
|
consume: (approvalId, action) =>
|
||||||
|
authz.consumeRuntimeTerminationApproval(approvalId, action),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
authz,
|
||||||
|
runtime,
|
||||||
|
durable,
|
||||||
|
);
|
||||||
|
const client = { data: { discordService: true }, emit: vi.fn() };
|
||||||
|
const plugin = new DiscordPlugin({
|
||||||
|
token: 'unused',
|
||||||
|
gatewayUrl: 'http://unused',
|
||||||
|
serviceToken: SERVICE_TOKEN,
|
||||||
|
allowedGuildIds: ['guild-1'],
|
||||||
|
allowedChannelIds: ['channel-1'],
|
||||||
|
allowedUserIds: ['discord-admin-1'],
|
||||||
|
interactionBindings: [
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
guildId: 'guild-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
pairedUsers: {
|
||||||
|
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const pluginInternals = plugin as unknown as {
|
||||||
|
client: { user: { id: string } };
|
||||||
|
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
||||||
|
handleDiscordMessage(message: unknown): void;
|
||||||
|
};
|
||||||
|
const pluginSocket = { connected: true, emit: vi.fn() };
|
||||||
|
pluginInternals.client = { user: { id: 'bot-1' } };
|
||||||
|
pluginInternals.socket = pluginSocket;
|
||||||
|
pluginInternals.handleDiscordMessage({
|
||||||
|
id: 'approve-1',
|
||||||
|
guildId: 'guild-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
author: { id: 'discord-admin-1', bot: false },
|
||||||
|
mentions: { has: () => true },
|
||||||
|
content: '<@bot-1> /approve',
|
||||||
|
channel: { parentId: null },
|
||||||
|
attachments: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
|
||||||
|
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
|
||||||
|
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
|
||||||
|
const approval = client.emit.mock.calls.find(
|
||||||
|
([event]) => event === 'discord:approval',
|
||||||
|
)?.[1] as {
|
||||||
|
approvalId: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(approval.success).toBe(true);
|
||||||
|
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
createDiscordIngressEnvelope(
|
||||||
|
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
|
||||||
|
SERVICE_TOKEN,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(terminated).toHaveBeenCalledWith(
|
||||||
|
'runtime-1',
|
||||||
|
approval.approvalId,
|
||||||
|
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||||
|
correlationId: 'discord-stop-correlation',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ export class AdminHealthController {
|
|||||||
async check(): Promise<HealthStatusDto> {
|
async check(): Promise<HealthStatusDto> {
|
||||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
||||||
|
|
||||||
const sessions = this.agentService.listSessions();
|
const sessions = this.agentService.listAllSessionsForSystem();
|
||||||
const providers = this.providerService.listProviders();
|
const providers = this.providerService.listProviders();
|
||||||
|
|
||||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||||
|
|||||||
179
apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts
Normal file
179
apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AgentService, type AgentSession } from '../agent.service.js';
|
||||||
|
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
||||||
|
|
||||||
|
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
|
||||||
|
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
|
||||||
|
|
||||||
|
type AgentServiceInternals = {
|
||||||
|
sessions: Map<string, AgentSession>;
|
||||||
|
creating: Map<string, Promise<AgentSession>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeService(operatorMemory: unknown = null): AgentService {
|
||||||
|
return new AgentService(
|
||||||
|
{
|
||||||
|
getDefaultModel: vi.fn(() => null),
|
||||||
|
getRegistry: vi.fn(() => ({})),
|
||||||
|
findModel: vi.fn(),
|
||||||
|
listAvailableModels: vi.fn(() => []),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ available: false } as never,
|
||||||
|
{} as never,
|
||||||
|
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||||
|
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
|
operatorMemory as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function internals(service: AgentService): AgentServiceInternals {
|
||||||
|
return service as unknown as AgentServiceInternals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
|
||||||
|
return {
|
||||||
|
id: CONVERSATION_ID,
|
||||||
|
provider: 'test-provider',
|
||||||
|
modelId: 'test-model',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'off',
|
||||||
|
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
||||||
|
setThinkingLevel: vi.fn(),
|
||||||
|
abort: vi.fn().mockResolvedValue(undefined),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
getSessionStats: vi.fn(),
|
||||||
|
getContextUsage: vi.fn(),
|
||||||
|
} as unknown as AgentSession['piSession'],
|
||||||
|
listeners: new Set(),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
promptCount: 0,
|
||||||
|
channels: new Set(),
|
||||||
|
skillPromptAdditions: [],
|
||||||
|
sandboxDir: '/tmp/tess-session-ownership-test',
|
||||||
|
allowedTools: null,
|
||||||
|
userId: scope.userId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
metrics: {
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
modelSwitches: 0,
|
||||||
|
messageCount: 0,
|
||||||
|
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentService owner/tenant scope enforcement', () => {
|
||||||
|
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const session = makeSession();
|
||||||
|
internals(service).sessions.set(CONVERSATION_ID, session);
|
||||||
|
|
||||||
|
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
|
||||||
|
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
||||||
|
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
||||||
|
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
|
||||||
|
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
|
||||||
|
|
||||||
|
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
|
||||||
|
expect(session.channels.has('websocket:owner')).toBe(true);
|
||||||
|
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
|
||||||
|
).toThrow(ForbiddenException);
|
||||||
|
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
|
||||||
|
expect(session.modelId).toBe('owner-model');
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
|
||||||
|
).toThrow(ForbiddenException);
|
||||||
|
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
|
||||||
|
expect(session.agentConfigId).toBe('agent-owner');
|
||||||
|
|
||||||
|
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
|
||||||
|
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
|
||||||
|
|
||||||
|
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
|
||||||
|
|
||||||
|
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
|
||||||
|
expect(session.piSession.dispose).toHaveBeenCalled();
|
||||||
|
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the operator-memory scope on the createSession production path', async () => {
|
||||||
|
const plugin = { capture: vi.fn(), search: vi.fn() };
|
||||||
|
const service = makeService(plugin);
|
||||||
|
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
|
||||||
|
|
||||||
|
// Session construction reaches the real scope derivation before the intentionally incomplete
|
||||||
|
// Pi test double rejects later in createAgentSession.
|
||||||
|
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
|
||||||
|
|
||||||
|
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
ownerId: OWNER_SCOPE.userId,
|
||||||
|
sessionId: CONVERSATION_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
|
||||||
|
const plugin = { capture: vi.fn(), search: vi.fn() };
|
||||||
|
const service = makeService(plugin);
|
||||||
|
internals(service).sessions.set(CONVERSATION_ID, makeSession());
|
||||||
|
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
|
||||||
|
|
||||||
|
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildTools).not.toHaveBeenCalled();
|
||||||
|
expect(plugin.capture).not.toHaveBeenCalled();
|
||||||
|
expect(plugin.search).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const session = makeSession();
|
||||||
|
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createSession(CONVERSATION_ID, {
|
||||||
|
userId: FOREIGN_SCOPE.userId,
|
||||||
|
tenantId: FOREIGN_SCOPE.tenantId,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createSession(CONVERSATION_ID, {
|
||||||
|
userId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
}),
|
||||||
|
).resolves.toBe(session);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
AgentRuntimeProvider,
|
||||||
|
RuntimeAttachHandle,
|
||||||
|
RuntimeAttachMode,
|
||||||
|
RuntimeCapability,
|
||||||
|
RuntimeCapabilitySet,
|
||||||
|
RuntimeHealth,
|
||||||
|
RuntimeMessage,
|
||||||
|
RuntimeScope,
|
||||||
|
RuntimeSession,
|
||||||
|
RuntimeSessionTree,
|
||||||
|
RuntimeStreamEvent,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
|
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
||||||
|
import {
|
||||||
|
RuntimeProviderAuditService,
|
||||||
|
RuntimeProviderService,
|
||||||
|
type RuntimeAuditEvent,
|
||||||
|
type RuntimeAuditSink,
|
||||||
|
type RuntimeApprovalVerifier,
|
||||||
|
} from '../runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
|
||||||
|
|
||||||
|
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
|
||||||
|
const CONTEXT = {
|
||||||
|
actorScope: OWNER_SCOPE,
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
||||||
|
readonly id = 'fleet';
|
||||||
|
readonly receivedScopes: RuntimeScope[] = [];
|
||||||
|
readonly sentMessages: RuntimeMessage[] = [];
|
||||||
|
terminateCalls = 0;
|
||||||
|
throwAfterSend = false;
|
||||||
|
throwAuthorization = false;
|
||||||
|
|
||||||
|
constructor(private readonly supported: RuntimeCapability[]) {}
|
||||||
|
|
||||||
|
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return { supported: this.supported };
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamSession(
|
||||||
|
_sessionId: string,
|
||||||
|
_cursor: string | undefined,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(
|
||||||
|
_sessionId: string,
|
||||||
|
message: RuntimeMessage,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<void> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
this.sentMessages.push(message);
|
||||||
|
if (this.throwAuthorization) {
|
||||||
|
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
|
||||||
|
}
|
||||||
|
if (this.throwAfterSend) {
|
||||||
|
throw new Error('provider acknowledgement failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
sessionId: string,
|
||||||
|
mode: RuntimeAttachMode,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return {
|
||||||
|
attachmentId: 'attachment-1',
|
||||||
|
sessionId,
|
||||||
|
mode,
|
||||||
|
expiresAt: '2026-07-12T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
this.terminateCalls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingAuditSink implements RuntimeAuditSink {
|
||||||
|
readonly events: RuntimeAuditEvent[] = [];
|
||||||
|
|
||||||
|
async record(event: RuntimeAuditEvent): Promise<void> {
|
||||||
|
this.events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
async consume(): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
|
||||||
|
|
||||||
|
async consume(
|
||||||
|
_approvalRef: string,
|
||||||
|
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
|
||||||
|
): Promise<boolean> {
|
||||||
|
this.consumedAction = action;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
provider: RecordingRuntimeProvider,
|
||||||
|
audit: RuntimeAuditSink = new RecordingAuditSink(),
|
||||||
|
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
|
||||||
|
): RuntimeProviderService {
|
||||||
|
const registry = new AgentRuntimeProviderRegistry();
|
||||||
|
registry.register(provider);
|
||||||
|
return new RuntimeProviderService(registry, audit, approval);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RuntimeProviderService security boundary', (): void => {
|
||||||
|
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const providerScope = provider.receivedScopes[0];
|
||||||
|
expect(providerScope).toEqual({
|
||||||
|
actorId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
channelId: CONTEXT.channelId,
|
||||||
|
correlationId: CONTEXT.correlationId,
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(providerScope)).toBe(true);
|
||||||
|
expect(audit.events).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
providerId: 'fleet',
|
||||||
|
operation: 'session.send',
|
||||||
|
outcome: 'succeeded',
|
||||||
|
actorId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
channelId: CONTEXT.channelId,
|
||||||
|
correlationId: CONTEXT.correlationId,
|
||||||
|
resourceId: 'session-1',
|
||||||
|
durationMs: expect.any(Number),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(audit.events)).not.toContain('hello');
|
||||||
|
expect(JSON.stringify(audit.events)).not.toContain('key-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
let persisted: unknown;
|
||||||
|
const durableAudit = new RuntimeProviderAuditService({
|
||||||
|
logs: {
|
||||||
|
ingest: async (entry: unknown): Promise<unknown> => {
|
||||||
|
persisted = entry;
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
const service = makeService(provider, durableAudit);
|
||||||
|
|
||||||
|
await service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session/credential-canary=secret-value',
|
||||||
|
{ content: 'safe message', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.sentMessages).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(persisted)).not.toContain('secret-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider([]);
|
||||||
|
const service = makeService(provider);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/capability denied/);
|
||||||
|
expect(provider.sentMessages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
||||||
|
const approval = new DenyingApprovalVerifier();
|
||||||
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit, approval);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
|
||||||
|
).rejects.toThrow(/approval denied/);
|
||||||
|
expect(provider.terminateCalls).toBe(0);
|
||||||
|
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
||||||
|
const approval = new AcceptingApprovalVerifier();
|
||||||
|
const service = makeService(provider, new RecordingAuditSink(), approval);
|
||||||
|
|
||||||
|
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
|
||||||
|
|
||||||
|
expect(approval.consumedAction).toEqual({
|
||||||
|
providerId: 'fleet',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
actorId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
channelId: CONTEXT.channelId,
|
||||||
|
correlationId: CONTEXT.correlationId,
|
||||||
|
agentName: process.env['MOSAIC_AGENT_NAME'],
|
||||||
|
});
|
||||||
|
expect(provider.terminateCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
const unavailableAudit: RuntimeAuditSink = {
|
||||||
|
async record(): Promise<void> {
|
||||||
|
throw new Error('audit unavailable');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = makeService(provider, unavailableAudit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/audit unavailable/);
|
||||||
|
expect(provider.sentMessages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
provider.throwAfterSend = true;
|
||||||
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/provider acknowledgement failed/);
|
||||||
|
expect(provider.sentMessages).toHaveLength(1);
|
||||||
|
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
|
||||||
|
'requested',
|
||||||
|
'failed',
|
||||||
|
]);
|
||||||
|
expect(audit.events.at(-1)).toMatchObject({
|
||||||
|
errorCode: 'provider_error',
|
||||||
|
durationMs: expect.any(Number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
provider.throwAuthorization = true;
|
||||||
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/provider authorization denied/);
|
||||||
|
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
|
||||||
|
let persisted: unknown;
|
||||||
|
const ingest = async (entry: unknown): Promise<unknown> => {
|
||||||
|
persisted = entry;
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
const service = new RuntimeProviderAuditService({ logs: { ingest } } as never);
|
||||||
|
|
||||||
|
await service.record({
|
||||||
|
providerId: 'fleet',
|
||||||
|
operation: 'session.send',
|
||||||
|
outcome: 'succeeded',
|
||||||
|
actorId: 'owner-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
resourceId: 'session-1',
|
||||||
|
durationMs: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(persisted).toMatchObject({
|
||||||
|
content: 'runtime.provider.audit',
|
||||||
|
metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }),
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(persisted)).not.toContain('approval');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
let auditCalls = 0;
|
||||||
|
const audit: RuntimeAuditSink = {
|
||||||
|
async record(): Promise<void> {
|
||||||
|
auditCalls += 1;
|
||||||
|
if (auditCalls === 2) {
|
||||||
|
throw new Error('completion audit unavailable');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(provider.sentMessages).toHaveLength(1);
|
||||||
|
expect(auditCalls).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
262
apps/gateway/src/agent/__tests__/session-ownership.test.ts
Normal file
262
apps/gateway/src/agent/__tests__/session-ownership.test.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
||||||
|
vi.mock('../../commands/command-executor.service.js', () => ({
|
||||||
|
CommandExecutorService: class CommandExecutorService {},
|
||||||
|
}));
|
||||||
|
vi.mock('../routing/routing-engine.service.js', () => ({
|
||||||
|
RoutingEngineService: class RoutingEngineService {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SessionsController } from '../sessions.controller.js';
|
||||||
|
import { ChatController } from '../../chat/chat.controller.js';
|
||||||
|
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||||
|
import type { AgentSession } from '../agent.service.js';
|
||||||
|
import type { SessionInfoDto } from '../session.dto.js';
|
||||||
|
|
||||||
|
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
|
||||||
|
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
||||||
|
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
|
||||||
|
|
||||||
|
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
|
||||||
|
return {
|
||||||
|
id: CONVERSATION_ID,
|
||||||
|
provider: 'test-provider',
|
||||||
|
modelId: 'test-model',
|
||||||
|
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||||
|
promptCount: 0,
|
||||||
|
channels: [],
|
||||||
|
durationMs: 0,
|
||||||
|
metrics: {
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
modelSwitches: 0,
|
||||||
|
messageCount: 0,
|
||||||
|
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAgentSession(owner = USER_A): AgentSession {
|
||||||
|
return {
|
||||||
|
id: CONVERSATION_ID,
|
||||||
|
provider: 'test-provider',
|
||||||
|
modelId: 'test-model',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'off',
|
||||||
|
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
||||||
|
setThinkingLevel: vi.fn(),
|
||||||
|
abort: vi.fn().mockResolvedValue(undefined),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
getSessionStats: vi.fn(),
|
||||||
|
getContextUsage: vi.fn(),
|
||||||
|
} as unknown as AgentSession['piSession'],
|
||||||
|
listeners: new Set(),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
promptCount: 0,
|
||||||
|
channels: new Set(),
|
||||||
|
skillPromptAdditions: [],
|
||||||
|
sandboxDir: '/tmp',
|
||||||
|
allowedTools: null,
|
||||||
|
userId: owner.id,
|
||||||
|
tenantId: owner.tenantId,
|
||||||
|
metrics: {
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
modelSwitches: 0,
|
||||||
|
messageCount: 0,
|
||||||
|
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeScopedAgentService() {
|
||||||
|
const foreign = makeAgentSession(USER_A);
|
||||||
|
return {
|
||||||
|
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
|
||||||
|
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
|
||||||
|
),
|
||||||
|
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
||||||
|
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
|
||||||
|
),
|
||||||
|
destroySession: vi.fn(),
|
||||||
|
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
||||||
|
scope?.userId === USER_B.id ? undefined : foreign,
|
||||||
|
),
|
||||||
|
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
|
||||||
|
onEvent: vi.fn(() => vi.fn()),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
||||||
|
it('requires explicit owner+tenant scope on protected session operations', () => {
|
||||||
|
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
||||||
|
|
||||||
|
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
|
||||||
|
expect(source).toContain('listSessions(scope: ActorTenantScope)');
|
||||||
|
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
|
||||||
|
expect(source).toContain(
|
||||||
|
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
||||||
|
);
|
||||||
|
expect(source).toContain(
|
||||||
|
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
||||||
|
);
|
||||||
|
expect(source).toContain(
|
||||||
|
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
|
||||||
|
);
|
||||||
|
expect(source).toContain('scope: ActorTenantScope,');
|
||||||
|
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
|
||||||
|
expect(source).not.toContain('scope?: ActorTenantScope');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
||||||
|
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
|
||||||
|
const agentService = makeScopedAgentService();
|
||||||
|
const controller = new SessionsController(agentService as never);
|
||||||
|
|
||||||
|
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
|
||||||
|
expect(agentService.listSessions).toHaveBeenCalledWith({
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reveal another owner/tenant session by guessed id', () => {
|
||||||
|
const agentService = makeScopedAgentService();
|
||||||
|
const controller = new SessionsController(agentService as never);
|
||||||
|
|
||||||
|
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
|
||||||
|
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not terminate another owner/tenant session by guessed id', async () => {
|
||||||
|
const agentService = makeScopedAgentService();
|
||||||
|
const controller = new SessionsController(agentService as never);
|
||||||
|
|
||||||
|
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
expect(agentService.destroySession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
||||||
|
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
||||||
|
const agentService = makeScopedAgentService();
|
||||||
|
const controller = new ChatController(agentService as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
|
||||||
|
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
|
||||||
|
function makeGateway(agentService = makeScopedAgentService()) {
|
||||||
|
const brain = {
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue(undefined),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
|
||||||
|
const commandExecutor = { execute: vi.fn() };
|
||||||
|
const routingEngine = {
|
||||||
|
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
||||||
|
};
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
agentService as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
commandRegistry as never,
|
||||||
|
commandExecutor as never,
|
||||||
|
routingEngine as never,
|
||||||
|
);
|
||||||
|
return { gateway, agentService };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSocket() {
|
||||||
|
return {
|
||||||
|
id: 'socket-b',
|
||||||
|
connected: true,
|
||||||
|
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
||||||
|
const { gateway, agentService } = makeGateway();
|
||||||
|
const socket = makeSocket();
|
||||||
|
|
||||||
|
await gateway.handleMessage(socket as never, {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
content: 'attach to foreign session',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
expect(agentService.onEvent).not.toHaveBeenCalled();
|
||||||
|
expect(agentService.addChannel).not.toHaveBeenCalled();
|
||||||
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect(socket.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate thinking level on another owner/tenant session', () => {
|
||||||
|
const { gateway, agentService } = makeGateway();
|
||||||
|
const socket = makeSocket();
|
||||||
|
|
||||||
|
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
||||||
|
|
||||||
|
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
expect(socket.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
||||||
|
const { gateway, agentService } = makeGateway();
|
||||||
|
const socket = makeSocket();
|
||||||
|
|
||||||
|
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
||||||
|
|
||||||
|
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||||
|
userId: USER_B.id,
|
||||||
|
tenantId: USER_B.tenantId,
|
||||||
|
});
|
||||||
|
expect(socket.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||||
@@ -8,24 +9,66 @@ import { SkillLoaderService } from './skill-loader.service.js';
|
|||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||||
|
import { InteractionController } from './interaction.controller.js';
|
||||||
import { RoutingController } from './routing/routing.controller.js';
|
import { RoutingController } from './routing/routing.controller.js';
|
||||||
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
import { LogModule } from '../log/log.module.js';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||||
|
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
||||||
|
import {
|
||||||
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
|
RUNTIME_APPROVAL_VERIFIER,
|
||||||
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
|
RuntimeProviderAuditService,
|
||||||
|
RuntimeProviderService,
|
||||||
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
|
||||||
|
const registry = new AgentRuntimeProviderRegistry();
|
||||||
|
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
|
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
||||||
providers: [
|
providers: [
|
||||||
ProviderService,
|
ProviderService,
|
||||||
ProviderCredentialsService,
|
ProviderCredentialsService,
|
||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
|
DurableSessionRepository,
|
||||||
|
DurableSessionService,
|
||||||
|
{
|
||||||
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
|
useFactory: createGatewayRuntimeProviderRegistry,
|
||||||
|
},
|
||||||
|
RuntimeProviderAuditService,
|
||||||
|
{
|
||||||
|
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
|
useExisting: RuntimeProviderAuditService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: RUNTIME_APPROVAL_VERIFIER,
|
||||||
|
useExisting: CommandRuntimeApprovalVerifier,
|
||||||
|
},
|
||||||
|
RuntimeProviderService,
|
||||||
AgentService,
|
AgentService,
|
||||||
],
|
],
|
||||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
controllers: [
|
||||||
|
ProvidersController,
|
||||||
|
SessionsController,
|
||||||
|
AgentConfigsController,
|
||||||
|
InteractionController,
|
||||||
|
RoutingController,
|
||||||
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AgentService,
|
AgentService,
|
||||||
ProviderService,
|
ProviderService,
|
||||||
@@ -33,6 +76,9 @@ import { GCModule } from '../gc/gc.module.js';
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
|
DurableSessionService,
|
||||||
|
RuntimeProviderService,
|
||||||
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
Optional,
|
||||||
|
type OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
@@ -8,9 +15,10 @@ import {
|
|||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
} from '@mariozechner/pi-coding-agent';
|
} from '@mariozechner/pi-coding-agent';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
import type { Memory } from '@mosaicstack/memory';
|
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { MEMORY } from '../memory/memory.tokens.js';
|
import { MEMORY } from '../memory/memory.tokens.js';
|
||||||
|
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
|
||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||||
import { CoordService } from '../coord/coord.service.js';
|
import { CoordService } from '../coord/coord.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
@@ -28,6 +36,7 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
|||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
|
||||||
/** A single message from DB conversation history, used for context injection. */
|
/** A single message from DB conversation history, used for context injection. */
|
||||||
export interface ConversationHistoryMessage {
|
export interface ConversationHistoryMessage {
|
||||||
@@ -68,6 +77,8 @@ export interface AgentSessionOptions {
|
|||||||
agentConfigId?: string;
|
agentConfigId?: string;
|
||||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
|
||||||
|
tenantId?: string;
|
||||||
/**
|
/**
|
||||||
* Prior conversation messages to inject as context when resuming a session.
|
* Prior conversation messages to inject as context when resuming a session.
|
||||||
* These messages are formatted and prepended to the system prompt so the
|
* These messages are formatted and prepended to the system prompt so the
|
||||||
@@ -94,6 +105,8 @@ export interface AgentSession {
|
|||||||
allowedTools: string[] | null;
|
allowedTools: string[] | null;
|
||||||
/** User ID that owns this session, used for preference lookups. */
|
/** User ID that owns this session, used for preference lookups. */
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
|
||||||
|
tenantId?: string;
|
||||||
/** Agent config ID applied to this session, if any (M5-001). */
|
/** Agent config ID applied to this session, if any (M5-001). */
|
||||||
agentConfigId?: string;
|
agentConfigId?: string;
|
||||||
/** Human-readable agent name applied to this session, if any (M5-001). */
|
/** Human-readable agent name applied to this session, if any (M5-001). */
|
||||||
@@ -123,6 +136,9 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(OPERATOR_MEMORY_PLUGIN)
|
||||||
|
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +150,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
private buildToolsForSandbox(
|
private buildToolsForSandbox(
|
||||||
sandboxDir: string,
|
sandboxDir: string,
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
|
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
...createBrainTools(this.brain),
|
...createBrainTools(this.brain),
|
||||||
@@ -142,6 +159,9 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
this.memory,
|
this.memory,
|
||||||
this.embeddingService.available ? this.embeddingService : null,
|
this.embeddingService.available ? this.embeddingService : null,
|
||||||
sessionUserId,
|
sessionUserId,
|
||||||
|
this.operatorMemory && sessionScope
|
||||||
|
? { plugin: this.operatorMemory, scope: sessionScope }
|
||||||
|
: undefined,
|
||||||
),
|
),
|
||||||
...createFileTools(sandboxDir),
|
...createFileTools(sandboxDir),
|
||||||
...createGitTools(sandboxDir),
|
...createGitTools(sandboxDir),
|
||||||
@@ -174,12 +194,20 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
.filter((t) => t.length > 0);
|
.filter((t) => t.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
|
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
|
||||||
|
const scope = this.scopeFromOptions(options);
|
||||||
const existing = this.sessions.get(sessionId);
|
const existing = this.sessions.get(sessionId);
|
||||||
if (existing) return existing;
|
if (existing) {
|
||||||
|
this.assertSessionScope(existing, scope);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
const inflight = this.creating.get(sessionId);
|
const inflight = this.creating.get(sessionId);
|
||||||
if (inflight) return inflight;
|
if (inflight) {
|
||||||
|
const session = await inflight;
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
||||||
this.creating.delete(sessionId);
|
this.creating.delete(sessionId);
|
||||||
@@ -208,6 +236,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
isAdmin: options.isAdmin,
|
isAdmin: options.isAdmin,
|
||||||
agentConfigId: options.agentConfigId,
|
agentConfigId: options.agentConfigId,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
|
tenantId: options.tenantId,
|
||||||
conversationHistory: options.conversationHistory,
|
conversationHistory: options.conversationHistory,
|
||||||
};
|
};
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -247,7 +276,15 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build per-session tools scoped to the sandbox directory and authenticated user
|
// Build per-session tools scoped to the sandbox directory and authenticated user
|
||||||
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
|
const sessionUserId = mergedOptions?.userId;
|
||||||
|
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
|
||||||
|
const sandboxTools = this.buildToolsForSandbox(
|
||||||
|
sandboxDir,
|
||||||
|
sessionUserId,
|
||||||
|
sessionUserId && sessionTenantId
|
||||||
|
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||||
@@ -342,6 +379,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
userId: mergedOptions?.userId,
|
||||||
|
tenantId: sessionTenantId,
|
||||||
agentConfigId: mergedOptions?.agentConfigId,
|
agentConfigId: mergedOptions?.agentConfigId,
|
||||||
agentName: resolvedAgentName,
|
agentName: resolvedAgentName,
|
||||||
metrics: {
|
metrics: {
|
||||||
@@ -473,38 +511,70 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
return this.providerService.getDefaultModel() ?? null;
|
return this.providerService.getDefaultModel() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSession(sessionId: string): AgentSession | undefined {
|
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
|
||||||
return this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
listSessions(): SessionInfoDto[] {
|
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return Array.from(this.sessions.values()).map((s) => ({
|
return Array.from(this.sessions.values())
|
||||||
id: s.id,
|
.filter((s) => this.sessionMatchesScope(s, scope))
|
||||||
provider: s.provider,
|
.map((s) => this.toSessionInfo(s, now));
|
||||||
modelId: s.modelId,
|
|
||||||
...(s.agentName ? { agentName: s.agentName } : {}),
|
|
||||||
createdAt: new Date(s.createdAt).toISOString(),
|
|
||||||
promptCount: s.promptCount,
|
|
||||||
channels: Array.from(s.channels),
|
|
||||||
durationMs: now - s.createdAt,
|
|
||||||
metrics: { ...s.metrics },
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
|
listAllSessionsForSystem(): SessionInfoDto[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
|
||||||
const s = this.sessions.get(sessionId);
|
const s = this.sessions.get(sessionId);
|
||||||
if (!s) return undefined;
|
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
|
||||||
|
return this.toSessionInfo(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
|
||||||
|
if (!options.userId) {
|
||||||
|
throw new ForbiddenException('Session owner scope is required');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
userId: options.userId,
|
||||||
provider: s.provider,
|
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
|
||||||
modelId: s.modelId,
|
};
|
||||||
...(s.agentName ? { agentName: s.agentName } : {}),
|
}
|
||||||
createdAt: new Date(s.createdAt).toISOString(),
|
|
||||||
promptCount: s.promptCount,
|
private tenantIdFor(
|
||||||
channels: Array.from(s.channels),
|
userId: string | undefined,
|
||||||
durationMs: Date.now() - s.createdAt,
|
tenantId: string | undefined,
|
||||||
metrics: { ...s.metrics },
|
): string | undefined {
|
||||||
|
return tenantId ?? userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
|
||||||
|
return (
|
||||||
|
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
|
||||||
|
if (!this.sessionMatchesScope(session, scope)) {
|
||||||
|
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
provider: session.provider,
|
||||||
|
modelId: session.modelId,
|
||||||
|
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||||
|
createdAt: new Date(session.createdAt).toISOString(),
|
||||||
|
promptCount: session.promptCount,
|
||||||
|
channels: Array.from(session.channels),
|
||||||
|
durationMs: now - session.createdAt,
|
||||||
|
metrics: { ...session.metrics },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,9 +623,10 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
* not reconstructed — the model is used on the next createSession call for
|
* not reconstructed — the model is used on the next createSession call for
|
||||||
* the same conversationId when the session is torn down or a new one is created.
|
* the same conversationId when the session is torn down or a new one is created.
|
||||||
*/
|
*/
|
||||||
updateSessionModel(sessionId: string, modelId: string): void {
|
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
const prev = session.modelId;
|
const prev = session.modelId;
|
||||||
session.modelId = modelId;
|
session.modelId = modelId;
|
||||||
this.recordModelSwitch(sessionId);
|
this.recordModelSwitch(sessionId);
|
||||||
@@ -572,48 +643,51 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
agentConfigId: string,
|
agentConfigId: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
modelId?: string,
|
modelId?: string,
|
||||||
): void {
|
): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
session.agentConfigId = agentConfigId;
|
session.agentConfigId = agentConfigId;
|
||||||
session.agentName = agentName;
|
session.agentName = agentName;
|
||||||
if (modelId) {
|
if (modelId) {
|
||||||
this.updateSessionModel(sessionId, modelId);
|
this.updateSessionModel(sessionId, modelId, scope);
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addChannel(sessionId: string, channel: string): void {
|
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (!session) return;
|
||||||
session.channels.add(channel);
|
this.assertSessionScope(session, scope);
|
||||||
}
|
session.channels.add(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeChannel(sessionId: string, channel: string): void {
|
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (!session) return;
|
||||||
session.channels.delete(channel);
|
this.assertSessionScope(session, scope);
|
||||||
}
|
session.channels.delete(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(sessionId: string, message: string): Promise<void> {
|
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
session.promptCount += 1;
|
session.promptCount += 1;
|
||||||
|
|
||||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||||
let effectiveMessage = message;
|
let effectiveMessage = message;
|
||||||
if (this.systemOverride) {
|
if (this.systemOverride) {
|
||||||
const override = await this.systemOverride.get(sessionId);
|
const override = await this.systemOverride.get(sessionId, scope);
|
||||||
if (override) {
|
if (override) {
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||||
await this.systemOverride.renew(sessionId);
|
await this.systemOverride.renew(sessionId, scope);
|
||||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -629,16 +703,28 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
|
onEvent(
|
||||||
|
sessionId: string,
|
||||||
|
listener: (event: AgentSessionEvent) => void,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
): () => void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
session.listeners.add(listener);
|
session.listeners.add(listener);
|
||||||
return () => session.listeners.delete(listener);
|
return () => session.listeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroySession(sessionId: string): Promise<void> {
|
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
|
await this.destroySessionForSystem(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async destroySessionForSystem(sessionId: string): Promise<void> {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.logger.log(`Destroying agent session ${sessionId}`);
|
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||||
@@ -667,7 +753,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
this.logger.log('Shutting down all agent sessions');
|
this.logger.log('Shutting down all agent sessions');
|
||||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
|
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
|
||||||
const results = await Promise.allSettled(stops);
|
const results = await Promise.allSettled(stops);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
|
|||||||
10
apps/gateway/src/agent/durable-session.dto.ts
Normal file
10
apps/gateway/src/agent/durable-session.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
/** Server-side request for a replay-safe provider message. */
|
||||||
|
export interface ProviderOutboxDto {
|
||||||
|
sessionId: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
correlationId: string;
|
||||||
|
content: string;
|
||||||
|
context: RuntimeProviderRequestContext;
|
||||||
|
}
|
||||||
416
apps/gateway/src/agent/durable-session.repository.test.ts
Normal file
416
apps/gateway/src/agent/durable-session.repository.test.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
|
||||||
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
||||||
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
|
|
||||||
|
const IDENTITY: DurableSessionIdentity = {
|
||||||
|
agentName: 'Nova',
|
||||||
|
sessionId: 'tess-pglite-session',
|
||||||
|
tenantId: 'tenant-pglite',
|
||||||
|
ownerId: 'tess-owner',
|
||||||
|
providerId: 'fleet',
|
||||||
|
runtimeSessionId: 'nova',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DurableSessionRepository', () => {
|
||||||
|
let dataDir: string | undefined;
|
||||||
|
let handle: DbHandle;
|
||||||
|
let previousAuthSecret: string | undefined;
|
||||||
|
|
||||||
|
beforeAll(async (): Promise<void> => {
|
||||||
|
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
|
||||||
|
handle = createPgliteDb(dataDir);
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
await seedOwner(handle);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
beforeEach(async (): Promise<void> => {
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
|
||||||
|
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async (): Promise<void> => {
|
||||||
|
await handle.close();
|
||||||
|
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
||||||
|
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
|
await beforeRestart.create(IDENTITY);
|
||||||
|
await beforeRestart.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'inbox-before-kill',
|
||||||
|
correlationId: 'correlation-before-kill',
|
||||||
|
content: 'resume after a kill',
|
||||||
|
});
|
||||||
|
await beforeRestart.enqueueOutbox({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'outbox-before-kill',
|
||||||
|
correlationId: 'correlation-before-kill',
|
||||||
|
channelId: 'cli',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'one response only',
|
||||||
|
});
|
||||||
|
await beforeRestart.checkpoint({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
checkpointId: 'checkpoint-before-kill',
|
||||||
|
cursor: 'cursor-before-kill',
|
||||||
|
summary: 'restart-safe state',
|
||||||
|
compactionEpoch: 1,
|
||||||
|
});
|
||||||
|
await beforeRestart.handoff({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
handoffId: 'handoff-before-kill',
|
||||||
|
destination: 'mos',
|
||||||
|
correlationId: 'correlation-before-kill',
|
||||||
|
checkpointId: 'checkpoint-before-kill',
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
await beforeRestart.checkpoint({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
checkpointId: 'checkpoint-after-handoff',
|
||||||
|
cursor: 'cursor-after-handoff',
|
||||||
|
summary: 'newer state cannot strand the portable handoff',
|
||||||
|
compactionEpoch: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handle.close();
|
||||||
|
handle = createPgliteDb(dataDir!);
|
||||||
|
|
||||||
|
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
|
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||||
|
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
||||||
|
const handled: string[] = [];
|
||||||
|
const effects: string[] = [];
|
||||||
|
|
||||||
|
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||||
|
handled.push(entry.idempotencyKey);
|
||||||
|
});
|
||||||
|
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||||
|
effects.push(entry.idempotencyKey);
|
||||||
|
});
|
||||||
|
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||||
|
handled.push(entry.idempotencyKey);
|
||||||
|
});
|
||||||
|
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||||
|
effects.push(entry.idempotencyKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recovered.identity).toEqual(IDENTITY);
|
||||||
|
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
|
||||||
|
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
|
||||||
|
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
|
||||||
|
expect(handled).toEqual(['inbox-before-kill']);
|
||||||
|
expect(effects).toEqual(['outbox-before-kill']);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('redacts sensitive durable payloads before persistence', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await coordinator.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'redacted-inbox',
|
||||||
|
correlationId: 'correlation-redaction',
|
||||||
|
content: 'api_key=super-secret-canary',
|
||||||
|
});
|
||||||
|
await coordinator.enqueueOutbox({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'redacted-outbox',
|
||||||
|
correlationId: 'correlation-redaction',
|
||||||
|
channelId: 'cli',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'email operator@example.test api_key=super-secret-canary',
|
||||||
|
});
|
||||||
|
await coordinator.checkpoint({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
checkpointId: 'redacted-checkpoint',
|
||||||
|
cursor: 'bearer super-secret-canary',
|
||||||
|
summary: 'email operator@example.test',
|
||||||
|
compactionEpoch: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
|
||||||
|
const [persisted] = await handle.db
|
||||||
|
.select({ content: interactionInbox.content })
|
||||||
|
.from(interactionInbox)
|
||||||
|
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
|
||||||
|
|
||||||
|
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
|
||||||
|
expect(JSON.stringify(snapshot)).not.toContain('operator@example.test');
|
||||||
|
expect(persisted?.content).not.toContain('super-secret-canary');
|
||||||
|
expect(persisted?.content).not.toContain('[REDACTED]');
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
coordinator.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'requires-idempotency-secret',
|
||||||
|
correlationId: 'correlation-secret',
|
||||||
|
content: 'sensitive payload',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/required for durable idempotency digests/);
|
||||||
|
} finally {
|
||||||
|
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
else process.env['BETTER_AUTH_SECRET'] = secret;
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
const input = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
checkpointId: 'checkpoint-secret-conflict',
|
||||||
|
cursor: 'api_key=secret-one',
|
||||||
|
summary: 'bearer secret-one',
|
||||||
|
compactionEpoch: 1,
|
||||||
|
};
|
||||||
|
await coordinator.checkpoint(input);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.checkpoint({
|
||||||
|
...input,
|
||||||
|
cursor: 'api_key=secret-two',
|
||||||
|
summary: 'bearer secret-two',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/checkpoint identity conflict/);
|
||||||
|
|
||||||
|
const [persisted] = await handle.db
|
||||||
|
.select({
|
||||||
|
digest: interactionCheckpoints.contentDigest,
|
||||||
|
cursor: interactionCheckpoints.cursor,
|
||||||
|
})
|
||||||
|
.from(interactionCheckpoints)
|
||||||
|
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
|
||||||
|
expect(persisted?.cursor).not.toContain('secret-one');
|
||||||
|
expect(persisted?.digest).not.toBe(
|
||||||
|
createHash('sha256')
|
||||||
|
.update(JSON.stringify([input.cursor, input.summary]))
|
||||||
|
.digest('hex'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await coordinator.checkpoint({
|
||||||
|
...input,
|
||||||
|
checkpointId: 'checkpoint-delimiter-conflict',
|
||||||
|
cursor: 'a\u0000b',
|
||||||
|
summary: 'c',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
coordinator.checkpoint({
|
||||||
|
...input,
|
||||||
|
checkpointId: 'checkpoint-delimiter-conflict',
|
||||||
|
cursor: 'a',
|
||||||
|
summary: 'b\u0000c',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/checkpoint identity conflict/);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
const inbox = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'inbox-secret-conflict',
|
||||||
|
correlationId: 'correlation-inbox-secret',
|
||||||
|
content: 'api_key=secret-one',
|
||||||
|
};
|
||||||
|
const outbox = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'outbox-secret-conflict',
|
||||||
|
correlationId: 'correlation-outbox-secret',
|
||||||
|
channelId: 'cli',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'api_key=secret-one',
|
||||||
|
};
|
||||||
|
await coordinator.receive(inbox);
|
||||||
|
await coordinator.enqueueOutbox(outbox);
|
||||||
|
|
||||||
|
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
|
||||||
|
/idempotency conflict/,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
|
||||||
|
).rejects.toThrow(/idempotency conflict/);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
||||||
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await coordinator.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'inbox-conflict',
|
||||||
|
correlationId: 'correlation-inbox',
|
||||||
|
content: 'original inbox',
|
||||||
|
});
|
||||||
|
await coordinator.enqueueOutbox({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'outbox-conflict',
|
||||||
|
correlationId: 'correlation-outbox',
|
||||||
|
channelId: 'cli',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'original outbox',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.receive({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'inbox-conflict',
|
||||||
|
correlationId: 'forged-correlation',
|
||||||
|
content: 'original inbox',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/idempotency conflict/);
|
||||||
|
await expect(
|
||||||
|
coordinator.enqueueOutbox({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'outbox-conflict',
|
||||||
|
correlationId: 'correlation-outbox',
|
||||||
|
channelId: 'forged-channel',
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: 'original outbox',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/idempotency conflict/);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
||||||
|
const repository = new DurableSessionRepository(handle.db);
|
||||||
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||||
|
const input = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'live-effect',
|
||||||
|
correlationId: 'correlation-live',
|
||||||
|
content: 'must not duplicate',
|
||||||
|
context: {
|
||||||
|
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-live',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await service.queueProviderSend(input);
|
||||||
|
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
|
||||||
|
status: 'processing',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
|
||||||
|
await expect(
|
||||||
|
service.recoverProviderSession(IDENTITY.sessionId, {
|
||||||
|
...input,
|
||||||
|
context: {
|
||||||
|
...input.context,
|
||||||
|
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/scope or correlation mismatch/);
|
||||||
|
|
||||||
|
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||||
|
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
||||||
|
const repository = new DurableSessionRepository(handle.db);
|
||||||
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||||
|
const input = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'mismatch-effect',
|
||||||
|
correlationId: 'correlation-expected',
|
||||||
|
content: 'must remain pending',
|
||||||
|
context: {
|
||||||
|
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-expected',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await service.queueProviderSend(input);
|
||||||
|
await expect(
|
||||||
|
service.dispatchProviderOutbox(IDENTITY.sessionId, {
|
||||||
|
...input,
|
||||||
|
correlationId: 'correlation-forged',
|
||||||
|
context: { ...input.context, correlationId: 'correlation-forged' },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/scope or correlation mismatch/);
|
||||||
|
|
||||||
|
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||||
|
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
||||||
|
const repository = new DurableSessionRepository(handle.db);
|
||||||
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||||
|
const first = {
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
idempotencyKey: 'scoped-effect-one',
|
||||||
|
correlationId: 'correlation-one',
|
||||||
|
content: 'first result',
|
||||||
|
context: {
|
||||||
|
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-one',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const second = {
|
||||||
|
...first,
|
||||||
|
idempotencyKey: 'scoped-effect-two',
|
||||||
|
correlationId: 'correlation-two',
|
||||||
|
content: 'second result',
|
||||||
|
context: { ...first.context, correlationId: 'correlation-two' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await coordinator.create(IDENTITY);
|
||||||
|
await service.queueProviderSend(first);
|
||||||
|
await service.queueProviderSend(second);
|
||||||
|
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
|
||||||
|
|
||||||
|
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
|
||||||
|
IDENTITY.providerId,
|
||||||
|
IDENTITY.runtimeSessionId,
|
||||||
|
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
|
||||||
|
first.context,
|
||||||
|
);
|
||||||
|
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||||
|
outbox: [
|
||||||
|
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
|
||||||
|
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedOwner(handle: DbHandle): Promise<void> {
|
||||||
|
await handle.db.execute(sql`
|
||||||
|
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||||
|
VALUES ('tess-owner', 'Tess Owner', 'tess-owner@example.test', false, now(), now())
|
||||||
|
`);
|
||||||
|
}
|
||||||
529
apps/gateway/src/agent/durable-session.repository.ts
Normal file
529
apps/gateway/src/agent/durable-session.repository.ts
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import { createHash, createHmac } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
interactionCheckpoints,
|
||||||
|
interactionHandoffs,
|
||||||
|
interactionInbox,
|
||||||
|
interactionOutbox,
|
||||||
|
interactionSessions,
|
||||||
|
type Db,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { seal, unseal } from '@mosaicstack/auth';
|
||||||
|
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||||
|
import type {
|
||||||
|
DurableCheckpoint,
|
||||||
|
DurableCheckpointInput,
|
||||||
|
DurableEnqueueResult,
|
||||||
|
DurableHandoff,
|
||||||
|
DurableHandoffInput,
|
||||||
|
DurableInboxEntry,
|
||||||
|
DurableInboxInput,
|
||||||
|
DurableInboxStatus,
|
||||||
|
DurableOutboxEntry,
|
||||||
|
DurableOutboxInput,
|
||||||
|
DurableOutboxStatus,
|
||||||
|
DurableSessionIdentity,
|
||||||
|
DurableSessionSnapshot,
|
||||||
|
DurableSessionStore,
|
||||||
|
} from '@mosaicstack/agent';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DurableSessionRepository implements DurableSessionStore {
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.insert(interactionSessions)
|
||||||
|
.values({
|
||||||
|
id: identity.sessionId,
|
||||||
|
agentName: identity.agentName,
|
||||||
|
tenantId: identity.tenantId,
|
||||||
|
ownerId: identity.ownerId,
|
||||||
|
providerId: identity.providerId,
|
||||||
|
runtimeSessionId: identity.runtimeSessionId,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
const existing = await this.session(identity.sessionId);
|
||||||
|
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
||||||
|
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
||||||
|
}
|
||||||
|
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
||||||
|
// the conversation handle and owner scope remain immutable.
|
||||||
|
if (
|
||||||
|
existing.providerId !== identity.providerId ||
|
||||||
|
existing.runtimeSessionId !== identity.runtimeSessionId
|
||||||
|
) {
|
||||||
|
await this.db
|
||||||
|
.update(interactionSessions)
|
||||||
|
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
|
||||||
|
.where(eq(interactionSessions.id, identity.sessionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
|
||||||
|
const identity = await this.session(sessionId);
|
||||||
|
if (!identity) return null;
|
||||||
|
|
||||||
|
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionInbox)
|
||||||
|
.where(eq(interactionInbox.sessionId, sessionId))
|
||||||
|
.orderBy(asc(interactionInbox.createdAt)),
|
||||||
|
this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionOutbox)
|
||||||
|
.where(eq(interactionOutbox.sessionId, sessionId))
|
||||||
|
.orderBy(asc(interactionOutbox.createdAt)),
|
||||||
|
this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionCheckpoints)
|
||||||
|
.where(eq(interactionCheckpoints.sessionId, sessionId))
|
||||||
|
.orderBy(
|
||||||
|
desc(interactionCheckpoints.compactionEpoch),
|
||||||
|
desc(interactionCheckpoints.createdAt),
|
||||||
|
)
|
||||||
|
.limit(1),
|
||||||
|
this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionHandoffs)
|
||||||
|
.where(eq(interactionHandoffs.sessionId, sessionId))
|
||||||
|
.orderBy(asc(interactionHandoffs.createdAt)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const checkpoint = checkpoints[0];
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
inbox: inbox.map(toInbox),
|
||||||
|
outbox: outbox.map(toOutbox),
|
||||||
|
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
|
||||||
|
handoffs: handoffs.map(toHandoff),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
||||||
|
const digest = contentDigest(input.content);
|
||||||
|
const record: DurableInboxInput = {
|
||||||
|
...input,
|
||||||
|
content: redactSensitiveContent(input.content).content,
|
||||||
|
};
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(interactionInbox)
|
||||||
|
.values({
|
||||||
|
...record,
|
||||||
|
content: seal(record.content),
|
||||||
|
contentDigest: digest,
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ status: interactionInbox.status });
|
||||||
|
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
||||||
|
|
||||||
|
const existing = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionInbox)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionInbox.sessionId, input.sessionId),
|
||||||
|
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
|
const entry = toInbox(existing[0]);
|
||||||
|
if (
|
||||||
|
!sameInbox(entry, record) ||
|
||||||
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
|
) {
|
||||||
|
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
|
}
|
||||||
|
return { accepted: false, status: entry.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
const candidate = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionInbox)
|
||||||
|
.where(
|
||||||
|
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
|
||||||
|
)
|
||||||
|
.orderBy(asc(interactionInbox.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
const entry = candidate[0];
|
||||||
|
if (!entry) return null;
|
||||||
|
const claimed = await this.db
|
||||||
|
.update(interactionInbox)
|
||||||
|
.set({ status: 'processing', updatedAt: new Date() })
|
||||||
|
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
|
||||||
|
.returning();
|
||||||
|
if (claimed[0]) return toInbox(claimed[0]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(interactionInbox)
|
||||||
|
.set({ status: 'processed', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionInbox.sessionId, sessionId),
|
||||||
|
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionInbox.status, 'processing'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(interactionInbox)
|
||||||
|
.set({ status: 'pending', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionInbox.sessionId, sessionId),
|
||||||
|
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionInbox.status, 'processing'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueueOutbox(
|
||||||
|
input: DurableOutboxInput,
|
||||||
|
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
||||||
|
const digest = contentDigest(input.content);
|
||||||
|
const record: DurableOutboxInput = {
|
||||||
|
...input,
|
||||||
|
content: redactSensitiveContent(input.content).content,
|
||||||
|
};
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(interactionOutbox)
|
||||||
|
.values({
|
||||||
|
...record,
|
||||||
|
content: seal(record.content),
|
||||||
|
contentDigest: digest,
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ status: interactionOutbox.status });
|
||||||
|
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
||||||
|
|
||||||
|
const existing = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionOutbox)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionOutbox.sessionId, input.sessionId),
|
||||||
|
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
|
const entry = toOutbox(existing[0]);
|
||||||
|
if (
|
||||||
|
!sameOutbox(entry, record) ||
|
||||||
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
|
) {
|
||||||
|
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
|
}
|
||||||
|
return { accepted: false, status: entry.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
const candidate = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionOutbox)
|
||||||
|
.where(
|
||||||
|
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
|
||||||
|
)
|
||||||
|
.orderBy(asc(interactionOutbox.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
const entry = candidate[0];
|
||||||
|
if (!entry) return null;
|
||||||
|
const claimed = await this.db
|
||||||
|
.update(interactionOutbox)
|
||||||
|
.set({ status: 'processing', updatedAt: new Date() })
|
||||||
|
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
|
||||||
|
.returning();
|
||||||
|
if (claimed[0]) return toOutbox(claimed[0]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimOutboxByKey(
|
||||||
|
sessionId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<DurableOutboxEntry | null> {
|
||||||
|
const claimed = await this.db
|
||||||
|
.update(interactionOutbox)
|
||||||
|
.set({ status: 'processing', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionOutbox.sessionId, sessionId),
|
||||||
|
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionOutbox.status, 'pending'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
return claimed[0] ? toOutbox(claimed[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(interactionOutbox)
|
||||||
|
.set({ status: 'delivered', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionOutbox.sessionId, sessionId),
|
||||||
|
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionOutbox.status, 'processing'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(interactionOutbox)
|
||||||
|
.set({ status: 'pending', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionOutbox.sessionId, sessionId),
|
||||||
|
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||||
|
eq(interactionOutbox.status, 'processing'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
||||||
|
// Compute identity before redaction. The persisted digest is keyed so a database
|
||||||
|
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
|
||||||
|
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
|
||||||
|
const checkpoint: DurableCheckpointInput = {
|
||||||
|
...input,
|
||||||
|
cursor: redactSensitiveContent(input.cursor).content,
|
||||||
|
summary: redactSensitiveContent(input.summary).content,
|
||||||
|
};
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(interactionCheckpoints)
|
||||||
|
.values({
|
||||||
|
...checkpoint,
|
||||||
|
contentDigest: digest,
|
||||||
|
cursor: seal(checkpoint.cursor),
|
||||||
|
summary: seal(checkpoint.summary),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ checkpointId: interactionCheckpoints.checkpointId });
|
||||||
|
if (inserted[0]) return;
|
||||||
|
|
||||||
|
const existing = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionCheckpoints)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionCheckpoints.sessionId, input.sessionId),
|
||||||
|
eq(interactionCheckpoints.checkpointId, input.checkpointId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (
|
||||||
|
!existing[0] ||
|
||||||
|
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
||||||
|
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
||||||
|
) {
|
||||||
|
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
|
||||||
|
const checkpoints = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionCheckpoints)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interactionCheckpoints.sessionId, sessionId),
|
||||||
|
eq(interactionCheckpoints.checkpointId, checkpointId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const checkpoint = checkpoints[0];
|
||||||
|
return checkpoint ? toCheckpoint(checkpoint) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||||
|
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
||||||
|
if (!checkpoint) {
|
||||||
|
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||||
|
}
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(interactionHandoffs)
|
||||||
|
.values({ ...input })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ handoffId: interactionHandoffs.handoffId });
|
||||||
|
if (inserted[0]) return;
|
||||||
|
|
||||||
|
const existing = await this.findHandoff(input.handoffId);
|
||||||
|
if (!existing || !sameHandoff(existing, input)) {
|
||||||
|
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
|
||||||
|
const handoffs = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionHandoffs)
|
||||||
|
.where(eq(interactionHandoffs.handoffId, handoffId))
|
||||||
|
.limit(1);
|
||||||
|
const handoff = handoffs[0];
|
||||||
|
return handoff ? toHandoff(handoff) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requeueInFlight(sessionId: string): Promise<void> {
|
||||||
|
// Inbox handlers are process-local work. A provider outbox claim may have
|
||||||
|
// reached an external target before a crash, so it is deliberately not
|
||||||
|
// replayed by generic recovery.
|
||||||
|
await this.db
|
||||||
|
.update(interactionInbox)
|
||||||
|
.set({ status: 'pending', updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
|
||||||
|
const sessions = await this.db
|
||||||
|
.select()
|
||||||
|
.from(interactionSessions)
|
||||||
|
.where(eq(interactionSessions.id, sessionId))
|
||||||
|
.limit(1);
|
||||||
|
const session = sessions[0];
|
||||||
|
return session
|
||||||
|
? {
|
||||||
|
agentName: session.agentName,
|
||||||
|
sessionId: session.id,
|
||||||
|
tenantId: session.tenantId,
|
||||||
|
ownerId: session.ownerId,
|
||||||
|
providerId: session.providerId,
|
||||||
|
runtimeSessionId: session.runtimeSessionId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentDigest(content: string): string {
|
||||||
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
|
||||||
|
}
|
||||||
|
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesContentDigest(stored: string, content: string): boolean {
|
||||||
|
return (
|
||||||
|
stored === contentDigest(content) ||
|
||||||
|
stored === createHash('sha256').update(content).digest('hex')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesCheckpointDigest(stored: string, digest: string): boolean {
|
||||||
|
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
|
||||||
|
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
|
||||||
|
return stored === digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
|
||||||
|
return (
|
||||||
|
left.agentName === right.agentName &&
|
||||||
|
left.sessionId === right.sessionId &&
|
||||||
|
left.tenantId === right.tenantId &&
|
||||||
|
left.ownerId === right.ownerId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
|
||||||
|
return (
|
||||||
|
left.sessionId === right.sessionId &&
|
||||||
|
left.idempotencyKey === right.idempotencyKey &&
|
||||||
|
left.correlationId === right.correlationId &&
|
||||||
|
left.content === right.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
|
||||||
|
return (
|
||||||
|
left.sessionId === right.sessionId &&
|
||||||
|
left.idempotencyKey === right.idempotencyKey &&
|
||||||
|
left.correlationId === right.correlationId &&
|
||||||
|
left.channelId === right.channelId &&
|
||||||
|
left.kind === right.kind &&
|
||||||
|
left.content === right.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
|
||||||
|
return (
|
||||||
|
left.sessionId === right.sessionId &&
|
||||||
|
left.checkpointId === right.checkpointId &&
|
||||||
|
left.compactionEpoch === right.compactionEpoch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
|
||||||
|
return (
|
||||||
|
left.sessionId === right.sessionId &&
|
||||||
|
left.handoffId === right.handoffId &&
|
||||||
|
left.destination === right.destination &&
|
||||||
|
left.correlationId === right.correlationId &&
|
||||||
|
left.checkpointId === right.checkpointId &&
|
||||||
|
left.status === right.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
|
||||||
|
return {
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
idempotencyKey: row.idempotencyKey,
|
||||||
|
correlationId: row.correlationId,
|
||||||
|
content: unseal(row.content),
|
||||||
|
status: row.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
|
||||||
|
return {
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
idempotencyKey: row.idempotencyKey,
|
||||||
|
correlationId: row.correlationId,
|
||||||
|
channelId: row.channelId,
|
||||||
|
kind: row.kind,
|
||||||
|
content: unseal(row.content),
|
||||||
|
status: row.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
|
||||||
|
return {
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
checkpointId: row.checkpointId,
|
||||||
|
cursor: unseal(row.cursor),
|
||||||
|
summary: unseal(row.summary),
|
||||||
|
compactionEpoch: row.compactionEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
|
||||||
|
return {
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
handoffId: row.handoffId,
|
||||||
|
destination: row.destination,
|
||||||
|
correlationId: row.correlationId,
|
||||||
|
checkpointId: row.checkpointId,
|
||||||
|
status: row.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
123
apps/gateway/src/agent/durable-session.service.ts
Normal file
123
apps/gateway/src/agent/durable-session.service.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
|
import type { ProviderOutboxDto } from './durable-session.dto.js';
|
||||||
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
|
import {
|
||||||
|
RuntimeProviderService,
|
||||||
|
type RuntimeProviderRequestContext,
|
||||||
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
|
||||||
|
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
||||||
|
* MCP adapters without a server-derived actor/tenant/correlation context.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DurableSessionService {
|
||||||
|
private readonly coordinator: DurableSessionCoordinator;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
|
||||||
|
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
||||||
|
) {
|
||||||
|
this.coordinator = new DurableSessionCoordinator(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
|
||||||
|
async enroll(
|
||||||
|
identity: DurableSessionIdentity,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
identity.ownerId !== context.actorScope.userId ||
|
||||||
|
identity.tenantId !== context.actorScope.tenantId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Durable session enrollment scope mismatch');
|
||||||
|
}
|
||||||
|
await this.coordinator.create(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
|
||||||
|
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
||||||
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
|
await this.coordinator.enqueueOutbox({
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
channelId: input.context.channelId,
|
||||||
|
kind: 'provider.send',
|
||||||
|
content: input.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||||
|
if (sessionId !== input.sessionId) {
|
||||||
|
throw new ForbiddenException('Durable outbox session mismatch');
|
||||||
|
}
|
||||||
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
|
const pendingEntry = snapshot.outbox.find(
|
||||||
|
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
|
||||||
|
);
|
||||||
|
if (!pendingEntry) return;
|
||||||
|
// Validate immutable routing before claiming. A caller with a mismatched
|
||||||
|
// correlation/channel must not strand a pending external side effect.
|
||||||
|
this.assertOutboxScope(pendingEntry, input);
|
||||||
|
await this.coordinator.dispatchOutboxEntry(
|
||||||
|
sessionId,
|
||||||
|
input.idempotencyKey,
|
||||||
|
async (entry): Promise<void> => {
|
||||||
|
this.assertOutboxScope(entry, input);
|
||||||
|
await this.runtimeProviders.sendMessage(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
|
||||||
|
input.context,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
|
||||||
|
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
|
||||||
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
|
||||||
|
sessionId,
|
||||||
|
content: '',
|
||||||
|
idempotencyKey: 'read-only',
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||||
|
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||||
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
|
await this.coordinator.recover(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertOutboxScope(
|
||||||
|
entry: { kind: string; correlationId: string; channelId: string },
|
||||||
|
input: ProviderOutboxDto,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
entry.kind !== 'provider.send' ||
|
||||||
|
entry.correlationId !== input.correlationId ||
|
||||||
|
entry.channelId !== input.context.channelId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
|
||||||
|
if (
|
||||||
|
input.context.actorScope.userId !== ownerId ||
|
||||||
|
input.context.actorScope.tenantId !== tenantId ||
|
||||||
|
input.context.correlationId !== input.correlationId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Durable session scope or correlation mismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts
Normal file
176
apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { HermesRuntimeProvider } from '@mosaicstack/agent';
|
||||||
|
import { AgentModule } from './agent.module.js';
|
||||||
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
import { LogModule } from '../log/log.module.js';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import {
|
||||||
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
|
RUNTIME_APPROVAL_VERIFIER,
|
||||||
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
|
RuntimeProviderAuditService,
|
||||||
|
} from './runtime-provider-registry.service.js';
|
||||||
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
|
import { AgentService } from './agent.service.js';
|
||||||
|
import { ProviderService } from './provider.service.js';
|
||||||
|
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||||
|
import { RoutingService } from './routing.service.js';
|
||||||
|
import { RoutingEngineService } from './routing/routing-engine.service.js';
|
||||||
|
import { SkillLoaderService } from './skill-loader.service.js';
|
||||||
|
|
||||||
|
const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' };
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
class EmptyAgentDependencyModule {}
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AUTH,
|
||||||
|
useValue: {
|
||||||
|
api: {
|
||||||
|
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
|
||||||
|
headers.get('cookie') === 'session=trusted'
|
||||||
|
? { user: authenticatedUser, session: { id: 'session-1' } }
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AuthGuard,
|
||||||
|
{ provide: BRAIN, useValue: {} },
|
||||||
|
{ provide: DB, useValue: {} },
|
||||||
|
],
|
||||||
|
exports: [AUTH, AuthGuard, BRAIN, DB],
|
||||||
|
})
|
||||||
|
class AuthenticatedRequestModule {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is deliberately an HTTP test rather than a controller unit test: it
|
||||||
|
* exercises AgentModule's actual provider factory, Nest DI, and AuthGuard.
|
||||||
|
*/
|
||||||
|
describe('Hermes runtime provider reachability', (): void => {
|
||||||
|
let app: NestFastifyApplication | undefined;
|
||||||
|
|
||||||
|
beforeAll(async (): Promise<void> => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AuthenticatedRequestModule, AgentModule],
|
||||||
|
})
|
||||||
|
.overrideModule(CoordModule)
|
||||||
|
.useModule(EmptyAgentDependencyModule)
|
||||||
|
.overrideModule(McpClientModule)
|
||||||
|
.useModule(EmptyAgentDependencyModule)
|
||||||
|
.overrideModule(SkillsModule)
|
||||||
|
.useModule(EmptyAgentDependencyModule)
|
||||||
|
.overrideModule(GCModule)
|
||||||
|
.useModule(EmptyAgentDependencyModule)
|
||||||
|
.overrideModule(LogModule)
|
||||||
|
.useModule(EmptyAgentDependencyModule)
|
||||||
|
.overrideModule(CommandsModule)
|
||||||
|
.useModule(EmptyAgentDependencyModule)
|
||||||
|
.overrideProvider(RuntimeProviderAuditService)
|
||||||
|
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
||||||
|
.overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
|
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
||||||
|
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
||||||
|
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
||||||
|
.overrideProvider(DurableSessionService)
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(DurableSessionRepository)
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(AgentService)
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(ProviderService)
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(ProviderCredentialsService)
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(RoutingService)
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(RoutingEngineService)
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(SkillLoaderService)
|
||||||
|
.useValue({})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async (): Promise<void> => {
|
||||||
|
await app?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise<void> => {
|
||||||
|
if (!app) throw new Error('Nest application did not initialize');
|
||||||
|
|
||||||
|
const attachDenied = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/interaction/Nova/sessions/session-1/attach',
|
||||||
|
headers: { 'x-correlation-id': 'correlation-1' },
|
||||||
|
payload: { mode: 'read' },
|
||||||
|
});
|
||||||
|
expect(attachDenied.statusCode).toBe(401);
|
||||||
|
|
||||||
|
const sendDenied = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/interaction/Nova/sessions/session-1/send',
|
||||||
|
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(sendDenied.statusCode).toBe(403);
|
||||||
|
expect(sendDenied.json()).toMatchObject({
|
||||||
|
message: 'Content and idempotency key are required',
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopDenied = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/interaction/Nova/sessions/session-1/stop',
|
||||||
|
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(stopDenied.statusCode).toBe(403);
|
||||||
|
expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
||||||
|
if (!app) throw new Error('Nest application did not initialize');
|
||||||
|
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
||||||
|
expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider);
|
||||||
|
|
||||||
|
const denied = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
|
||||||
|
headers: { 'x-correlation-id': 'correlation-1' },
|
||||||
|
});
|
||||||
|
expect(denied.statusCode).toBe(401);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
|
||||||
|
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual([
|
||||||
|
{ capability: 'kanban', status: 'unsupported' },
|
||||||
|
{ capability: 'skills', status: 'unsupported' },
|
||||||
|
{ capability: 'memory', status: 'unsupported' },
|
||||||
|
{ capability: 'tools', status: 'unsupported' },
|
||||||
|
{ capability: 'cron', status: 'unsupported' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
apps/gateway/src/agent/hermes-runtime.transport.test.ts
Normal file
46
apps/gateway/src/agent/hermes-runtime.transport.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
||||||
|
|
||||||
|
const scope = {
|
||||||
|
actorId: 'owner-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GatewayHermesRuntimeTransport', () => {
|
||||||
|
it('preserves a configured path prefix and authenticates the concrete runtime request', async () => {
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 }));
|
||||||
|
const transport = new GatewayHermesRuntimeTransport(
|
||||||
|
'https://runtime.example.test/hermes',
|
||||||
|
'test-service-token',
|
||||||
|
fetchFn,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']);
|
||||||
|
|
||||||
|
expect(fetchFn).toHaveBeenCalledWith(
|
||||||
|
new URL('https://runtime.example.test/hermes/capabilities'),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
authorization: 'Bearer test-service-token',
|
||||||
|
'x-mosaic-channel-id': 'cli',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => {
|
||||||
|
const fetchFn = vi.fn();
|
||||||
|
const transport = new GatewayHermesRuntimeTransport(
|
||||||
|
'http://runtime.example.test/hermes',
|
||||||
|
'test-service-token',
|
||||||
|
fetchFn,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS');
|
||||||
|
expect(fetchFn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
121
apps/gateway/src/agent/hermes-runtime.transport.ts
Normal file
121
apps/gateway/src/agent/hermes-runtime.transport.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent';
|
||||||
|
import type {
|
||||||
|
RuntimeAttachHandle,
|
||||||
|
RuntimeAttachMode,
|
||||||
|
RuntimeMessage,
|
||||||
|
RuntimeScope,
|
||||||
|
RuntimeStreamEvent,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */
|
||||||
|
export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport {
|
||||||
|
constructor(
|
||||||
|
private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(),
|
||||||
|
private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(),
|
||||||
|
private readonly fetchFn: typeof fetch = fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async capabilities(scope: RuntimeScope): Promise<string[]> {
|
||||||
|
return this.request<string[]>('/capabilities', scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> {
|
||||||
|
return this.request<{ status: string; detail?: string }>('/health', scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sessions(scope: RuntimeScope): Promise<HermesLegacySession[]> {
|
||||||
|
return this.request<HermesLegacySession[]>('/sessions', scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async *stream(
|
||||||
|
sessionId: string,
|
||||||
|
cursor: string | undefined,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
const params = new URLSearchParams(cursor ? { cursor } : {});
|
||||||
|
const events = await this.request<RuntimeStreamEvent[]>(
|
||||||
|
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
yield* events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
|
||||||
|
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
|
||||||
|
method: 'POST',
|
||||||
|
body: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
sessionId: string,
|
||||||
|
mode: RuntimeAttachMode,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
return this.request<RuntimeAttachHandle>(
|
||||||
|
`/sessions/${encodeURIComponent(sessionId)}/attach`,
|
||||||
|
scope,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { mode },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { approvalRef },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
path: string,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
init: { method?: string; body?: unknown } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
if (!this.baseUrl || !this.serviceToken) {
|
||||||
|
throw new Error(
|
||||||
|
'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const endpoint = new URL(this.baseUrl);
|
||||||
|
if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) {
|
||||||
|
throw new Error('Hermes runtime transport requires HTTPS outside loopback');
|
||||||
|
}
|
||||||
|
const response = await this.fetchFn(
|
||||||
|
new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`),
|
||||||
|
{
|
||||||
|
method: init.method ?? 'GET',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${this.serviceToken}`,
|
||||||
|
'x-mosaic-actor-id': scope.actorId,
|
||||||
|
'x-mosaic-tenant-id': scope.tenantId,
|
||||||
|
'x-mosaic-channel-id': scope.channelId,
|
||||||
|
'x-correlation-id': scope.correlationId,
|
||||||
|
...(init.body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
...(init.body ? { body: JSON.stringify(init.body) } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`);
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHttp(endpoint: URL): boolean {
|
||||||
|
return (
|
||||||
|
endpoint.protocol === 'http:' &&
|
||||||
|
(endpoint.hostname === 'localhost' ||
|
||||||
|
endpoint.hostname === '127.0.0.1' ||
|
||||||
|
endpoint.hostname === '::1')
|
||||||
|
);
|
||||||
|
}
|
||||||
263
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
263
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
RuntimeApprovalDeniedError,
|
||||||
|
RuntimeProviderService,
|
||||||
|
} from './runtime-provider-registry.service.js';
|
||||||
|
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
|
import { InteractionController } from './interaction.controller.js';
|
||||||
|
|
||||||
|
describe('InteractionController', (): void => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('maps a denied runtime approval to Fastify HTTP 403', () => {
|
||||||
|
const send = vi.fn();
|
||||||
|
const status = vi.fn().mockReturnValue({ send });
|
||||||
|
const response = { status };
|
||||||
|
const host = { switchToHttp: () => ({ getResponse: () => response }) };
|
||||||
|
|
||||||
|
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
|
||||||
|
|
||||||
|
expect(status).toHaveBeenCalledWith(403);
|
||||||
|
expect(send).toHaveBeenCalledWith({
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Runtime termination approval denied',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a differently named configured instance without a code change', async () => {
|
||||||
|
const prior = process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
|
||||||
|
const controller = new InteractionController(runtime as never, {} as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
await expect(
|
||||||
|
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||||
|
).rejects.toThrow('Interaction agent is not configured');
|
||||||
|
|
||||||
|
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const registry = createGatewayRuntimeProviderRegistry();
|
||||||
|
const runtime = new RuntimeProviderService(
|
||||||
|
registry,
|
||||||
|
{ record: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
{ consume: vi.fn().mockResolvedValue(false) },
|
||||||
|
);
|
||||||
|
const controller = new InteractionController(runtime, {} as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.transitionalCapabilities(
|
||||||
|
'Nova',
|
||||||
|
'runtime.hermes',
|
||||||
|
{ id: 'owner', tenantId: 'team' },
|
||||||
|
'corr-1',
|
||||||
|
),
|
||||||
|
).resolves.toEqual([
|
||||||
|
{ capability: 'kanban', status: 'unsupported' },
|
||||||
|
{ capability: 'skills', status: 'unsupported' },
|
||||||
|
{ capability: 'memory', status: 'unsupported' },
|
||||||
|
{ capability: 'tools', status: 'unsupported' },
|
||||||
|
{ capability: 'cron', status: 'unsupported' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a request without the non-simple correlation header', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
||||||
|
|
||||||
|
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
|
||||||
|
'X-Correlation-Id is required',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = {
|
||||||
|
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
||||||
|
};
|
||||||
|
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.enroll(
|
||||||
|
'Nova',
|
||||||
|
'conversation-1',
|
||||||
|
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
{ id: 'owner', tenantId: 'team' },
|
||||||
|
'corr-1',
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
|
||||||
|
expect(durable.enroll).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
agentName: 'Nova',
|
||||||
|
sessionId: 'conversation-1',
|
||||||
|
tenantId: 'team',
|
||||||
|
ownerId: 'owner',
|
||||||
|
providerId: 'fleet',
|
||||||
|
runtimeSessionId: 'runtime-1',
|
||||||
|
},
|
||||||
|
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid attach mode before invoking a provider', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
|
||||||
|
).rejects.toThrow('Interaction attach mode is invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes a durable session by attaching and streaming its runtime events', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtimeEvent = {
|
||||||
|
type: 'message.delta' as const,
|
||||||
|
sessionId: 'runtime-1',
|
||||||
|
cursor: 'cursor-1',
|
||||||
|
occurredAt: '2026-07-13T00:00:00.000Z',
|
||||||
|
content: 'resumed',
|
||||||
|
};
|
||||||
|
const runtime = {
|
||||||
|
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
|
||||||
|
streamSession: vi.fn(async function* () {
|
||||||
|
yield runtimeEvent;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const durable = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
|
||||||
|
await expect(
|
||||||
|
firstValueFrom(
|
||||||
|
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ data: runtimeEvent });
|
||||||
|
|
||||||
|
expect(runtime.attach).toHaveBeenCalledWith(
|
||||||
|
'fleet',
|
||||||
|
'runtime-1',
|
||||||
|
'read',
|
||||||
|
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||||
|
);
|
||||||
|
expect(runtime.streamSession).toHaveBeenCalledWith(
|
||||||
|
'fleet',
|
||||||
|
'runtime-1',
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
|
||||||
|
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
|
||||||
|
resolveSnapshot = resolve;
|
||||||
|
});
|
||||||
|
const runtime = { streamSession: vi.fn() };
|
||||||
|
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
const subscription = controller
|
||||||
|
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
|
||||||
|
.subscribe();
|
||||||
|
subscription.unsubscribe();
|
||||||
|
resolveSnapshot({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(runtime.streamSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
|
||||||
|
[
|
||||||
|
'session-agent mismatch',
|
||||||
|
{
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('denies a CLI stop for %s', async (_reason, durable) => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.stop(
|
||||||
|
'Nova',
|
||||||
|
'durable-1',
|
||||||
|
{ approvalRef: 'approval-1' },
|
||||||
|
{ id: 'owner' },
|
||||||
|
'corr-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
expect(runtime.terminate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = {
|
||||||
|
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
|
||||||
|
};
|
||||||
|
const durable = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.stop(
|
||||||
|
'Nova',
|
||||||
|
'durable-1',
|
||||||
|
{ approvalRef: 'approval-1' },
|
||||||
|
{ id: 'owner' },
|
||||||
|
'corr-1',
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Runtime termination approval denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the durable session identity and runtime registry for an approved stop', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const durable = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new InteractionController(runtime as never, durable as never);
|
||||||
|
|
||||||
|
await controller.stop(
|
||||||
|
'Nova',
|
||||||
|
'durable-1',
|
||||||
|
{ approvalRef: 'approval-1' },
|
||||||
|
{ id: 'owner' },
|
||||||
|
'corr-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runtime.terminate).toHaveBeenCalledWith(
|
||||||
|
'fleet',
|
||||||
|
'runtime-1',
|
||||||
|
'approval-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
actorScope: { userId: 'owner', tenantId: 'owner' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
293
apps/gateway/src/agent/interaction.controller.ts
Normal file
293
apps/gateway/src/agent/interaction.controller.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Sse,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
UseFilters,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
|
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
|
import {
|
||||||
|
RuntimeProviderService,
|
||||||
|
type RuntimeProviderRequestContext,
|
||||||
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated HTTP boundary for operator interaction clients. Identity is
|
||||||
|
* selected from deployment configuration, never a client-side command name.
|
||||||
|
*/
|
||||||
|
@Controller('api/interaction/:agentName')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@UseFilters(RuntimeApprovalDeniedFilter)
|
||||||
|
export class InteractionController {
|
||||||
|
constructor(
|
||||||
|
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||||
|
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('sessions')
|
||||||
|
async sessions(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Query('provider') providerId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
return this.runtime.listSessions(
|
||||||
|
this.requiredProvider(providerId),
|
||||||
|
this.context(user, correlationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('transitional-capabilities')
|
||||||
|
async transitionalCapabilities(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Query('provider') providerId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
return this.runtime.transitionalCapabilityMatrix(
|
||||||
|
this.requiredProvider(providerId),
|
||||||
|
this.context(user, correlationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tree')
|
||||||
|
async tree(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Query('provider') providerId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
return this.runtime.getSessionTree(
|
||||||
|
this.requiredProvider(providerId),
|
||||||
|
this.context(user, correlationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind an existing, authorized runtime session to the stable conversation ID.
|
||||||
|
* This is the lifecycle boundary where both runtime identifiers are known.
|
||||||
|
*/
|
||||||
|
@Post('sessions/:sessionId/enroll')
|
||||||
|
async enroll(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
const providerId = this.requiredProvider(body.providerId ?? '');
|
||||||
|
const runtimeSessionId = body.runtimeSessionId?.trim();
|
||||||
|
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const sessions = await this.runtime.listSessions(providerId, context);
|
||||||
|
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
|
||||||
|
throw new ForbiddenException('Runtime session is not visible to this actor');
|
||||||
|
}
|
||||||
|
await this.durable.enroll(
|
||||||
|
{
|
||||||
|
agentName,
|
||||||
|
sessionId,
|
||||||
|
tenantId: context.actorScope.tenantId,
|
||||||
|
ownerId: context.actorScope.userId,
|
||||||
|
providerId,
|
||||||
|
runtimeSessionId,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
return { status: 'enrolled', sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sessions/:sessionId/attach')
|
||||||
|
async attach(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() body: { mode?: RuntimeAttachMode } = {},
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const mode = body.mode ?? 'read';
|
||||||
|
if (mode !== 'read' && mode !== 'control') {
|
||||||
|
throw new ForbiddenException('Interaction attach mode is invalid');
|
||||||
|
}
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
return this.runtime.attach(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
mode,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sse('sessions/:sessionId/stream')
|
||||||
|
stream(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Query('cursor') cursor: string | undefined,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
): Observable<{ data: RuntimeStreamEvent }> {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
return new Observable((subscriber) => {
|
||||||
|
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
if (cancelled || subscriber.closed) return;
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
iterator = this.runtime
|
||||||
|
.streamSession(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
cursor?.trim() || undefined,
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
[Symbol.asyncIterator]();
|
||||||
|
if (cancelled || subscriber.closed) {
|
||||||
|
await iterator.return?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (!cancelled && !subscriber.closed) {
|
||||||
|
const next = await iterator.next();
|
||||||
|
if (next.done || cancelled || subscriber.closed) break;
|
||||||
|
subscriber.next({ data: next.value });
|
||||||
|
}
|
||||||
|
if (!subscriber.closed) subscriber.complete();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!subscriber.closed) subscriber.error(error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
void iterator?.return?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sessions/:sessionId/send')
|
||||||
|
async send(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() body: { content?: string; idempotencyKey?: string } = {},
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
|
||||||
|
throw new ForbiddenException('Content and idempotency key are required');
|
||||||
|
}
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
const input = {
|
||||||
|
sessionId,
|
||||||
|
content: body.content,
|
||||||
|
idempotencyKey: body.idempotencyKey,
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
await this.durable.queueProviderSend(input);
|
||||||
|
await this.durable.dispatchProviderOutbox(sessionId, input);
|
||||||
|
return { status: 'queued', sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sessions/:sessionId/stop')
|
||||||
|
async stop(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Body() body: { approvalRef?: string } = {},
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
if (!body.approvalRef?.trim())
|
||||||
|
throw new ForbiddenException('Exact-action approval is required');
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
await this.runtime.terminate(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
body.approvalRef,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
return { status: 'stopped', sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sessions/:sessionId/recover')
|
||||||
|
async recover(
|
||||||
|
@Param('agentName') agentName: string,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
) {
|
||||||
|
this.assertConfiguredAgent(agentName);
|
||||||
|
const context = this.context(user, correlationId);
|
||||||
|
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||||
|
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||||
|
await this.durable.recoverProviderSession(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
content: '',
|
||||||
|
idempotencyKey: `recovery:${context.correlationId}`,
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
return { status: 'recovered', sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private context(
|
||||||
|
user: AuthenticatedUserLike,
|
||||||
|
correlationId?: string,
|
||||||
|
): RuntimeProviderRequestContext {
|
||||||
|
const requestCorrelationId = correlationId?.trim();
|
||||||
|
// This non-simple request header is mandatory for mutations. Browser
|
||||||
|
// cross-origin requests cannot set it without a CORS preflight, and the
|
||||||
|
// gateway's allowlist rejects untrusted origins before the handler runs.
|
||||||
|
if (!requestCorrelationId) {
|
||||||
|
throw new ForbiddenException('X-Correlation-Id is required');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
actorScope: scopeFromUser(user),
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: requestCorrelationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertConfiguredAgent(agentName: string): void {
|
||||||
|
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
|
if (!configured || configured !== agentName) {
|
||||||
|
throw new ForbiddenException('Interaction agent is not configured for this request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
|
||||||
|
if (sessionAgentName !== agentName)
|
||||||
|
throw new ForbiddenException('Interaction session identity mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
private requiredProvider(providerId: string): string {
|
||||||
|
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
|
||||||
|
return providerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +1,10 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
import { seal, unseal } from '@mosaicstack/auth';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaicstack/db';
|
||||||
import { providerCredentials, eq, and } from '@mosaicstack/db';
|
import { providerCredentials, eq, and } from '@mosaicstack/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
|
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
|
||||||
const IV_LENGTH = 12; // 96-bit IV for GCM
|
|
||||||
const TAG_LENGTH = 16; // 128-bit auth tag
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
|
|
||||||
* The secret is assumed to be set in the environment.
|
|
||||||
*/
|
|
||||||
function deriveEncryptionKey(): Buffer {
|
|
||||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
|
||||||
if (!secret) {
|
|
||||||
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
|
|
||||||
}
|
|
||||||
return createHash('sha256').update(secret).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a plain-text value using AES-256-GCM.
|
|
||||||
* Output format: base64(iv + authTag + ciphertext)
|
|
||||||
*/
|
|
||||||
function encrypt(plaintext: string): string {
|
|
||||||
const key = deriveEncryptionKey();
|
|
||||||
const iv = randomBytes(IV_LENGTH);
|
|
||||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
||||||
|
|
||||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
|
|
||||||
// Combine iv (12) + authTag (16) + ciphertext and base64-encode
|
|
||||||
const combined = Buffer.concat([iv, authTag, encrypted]);
|
|
||||||
return combined.toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a value encrypted by `encrypt()`.
|
|
||||||
* Throws on authentication failure (tampered data).
|
|
||||||
*/
|
|
||||||
function decrypt(encoded: string): string {
|
|
||||||
const key = deriveEncryptionKey();
|
|
||||||
const combined = Buffer.from(encoded, 'base64');
|
|
||||||
|
|
||||||
const iv = combined.subarray(0, IV_LENGTH);
|
|
||||||
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
||||||
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
|
||||||
|
|
||||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
|
|
||||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
||||||
return decrypted.toString('utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProviderCredentialsService {
|
export class ProviderCredentialsService {
|
||||||
private readonly logger = new Logger(ProviderCredentialsService.name);
|
private readonly logger = new Logger(ProviderCredentialsService.name);
|
||||||
@@ -74,7 +22,7 @@ export class ProviderCredentialsService {
|
|||||||
value: string,
|
value: string,
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const encryptedValue = encrypt(value);
|
const encryptedValue = seal(value);
|
||||||
|
|
||||||
await this.db
|
await this.db
|
||||||
.insert(providerCredentials)
|
.insert(providerCredentials)
|
||||||
@@ -122,7 +70,7 @@ export class ProviderCredentialsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return decrypt(row.encryptedValue);
|
return unseal(row.encryptedValue);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
`Failed to decrypt credential for user=${userId} provider=${provider}`,
|
||||||
|
|||||||
@@ -107,8 +107,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
|
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
|
||||||
*/
|
*/
|
||||||
private startHealthCheckScheduler(): void {
|
private startHealthCheckScheduler(): void {
|
||||||
const intervalSecs =
|
const intervalSecs = this.effectiveHealthCheckIntervalSecs();
|
||||||
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS;
|
|
||||||
const intervalMs = intervalSecs * 1000;
|
const intervalMs = intervalSecs * 1000;
|
||||||
|
|
||||||
// Run an initial check immediately (non-blocking)
|
// Run an initial check immediately (non-blocking)
|
||||||
@@ -176,6 +175,28 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the effective provider operational policy without credentials,
|
||||||
|
* endpoints, request content, or provider error details.
|
||||||
|
*/
|
||||||
|
getEffectivePolicyStatus(): {
|
||||||
|
healthCheckIntervalSecs: number;
|
||||||
|
configuredProviders: string[];
|
||||||
|
availableModelCount: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
healthCheckIntervalSecs: this.effectiveHealthCheckIntervalSecs(),
|
||||||
|
configuredProviders: this.adapters.map((adapter) => adapter.name),
|
||||||
|
availableModelCount: this.registry?.getAvailable().length ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private effectiveHealthCheckIntervalSecs(): number {
|
||||||
|
return (
|
||||||
|
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Adapter-pattern API
|
// Adapter-pattern API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
46
apps/gateway/src/agent/providers.controller.test.ts
Normal file
46
apps/gateway/src/agent/providers.controller.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ProvidersController } from './providers.controller.js';
|
||||||
|
|
||||||
|
describe('ProvidersController operational status', (): void => {
|
||||||
|
it('reports provider latency and effective policy without exposing provider error details', (): void => {
|
||||||
|
const providerService = {
|
||||||
|
getProvidersHealth: vi.fn(() => [
|
||||||
|
{
|
||||||
|
name: 'fleet',
|
||||||
|
status: 'down',
|
||||||
|
latencyMs: 42,
|
||||||
|
lastChecked: '2026-07-12T00:00:00.000Z',
|
||||||
|
modelCount: 0,
|
||||||
|
error: 'credential-canary=secret-value',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
getEffectivePolicyStatus: vi.fn(() => ({
|
||||||
|
healthCheckIntervalSecs: 60,
|
||||||
|
configuredProviders: ['fleet'],
|
||||||
|
availableModelCount: 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const controller = new ProvidersController(providerService as never, {} as never, {} as never);
|
||||||
|
|
||||||
|
const status = controller.status();
|
||||||
|
|
||||||
|
expect(status).toEqual({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: 'fleet',
|
||||||
|
status: 'down',
|
||||||
|
latencyMs: 42,
|
||||||
|
lastChecked: '2026-07-12T00:00:00.000Z',
|
||||||
|
modelCount: 0,
|
||||||
|
errorCode: 'provider_unavailable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
effectivePolicy: {
|
||||||
|
healthCheckIntervalSecs: 60,
|
||||||
|
configuredProviders: ['fleet'],
|
||||||
|
availableModelCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(status)).not.toContain('secret-value');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,7 +33,20 @@ export class ProvidersController {
|
|||||||
|
|
||||||
@Get('health')
|
@Get('health')
|
||||||
health() {
|
health() {
|
||||||
return { providers: this.providerService.getProvidersHealth() };
|
return { providers: this.safeProviderHealth() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe operational status for troubleshooting and readiness checks. Provider
|
||||||
|
* errors are reduced to a stable code so credentials and remote responses
|
||||||
|
* cannot leak through this endpoint.
|
||||||
|
*/
|
||||||
|
@Get('status')
|
||||||
|
status() {
|
||||||
|
return {
|
||||||
|
providers: this.safeProviderHealth(),
|
||||||
|
effectivePolicy: this.providerService.getEffectivePolicyStatus(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('test')
|
@Post('test')
|
||||||
@@ -51,6 +64,13 @@ export class ProvidersController {
|
|||||||
return this.routingService.rank(criteria);
|
return this.routingService.rank(criteria);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private safeProviderHealth() {
|
||||||
|
return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({
|
||||||
|
...provider,
|
||||||
|
...(error ? { errorCode: 'provider_unavailable' } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Credential CRUD ──────────────────────────────────────────────────────
|
// ── Credential CRUD ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
13
apps/gateway/src/agent/runtime-approval-denied.filter.ts
Normal file
13
apps/gateway/src/agent/runtime-approval-denied.filter.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common';
|
||||||
|
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */
|
||||||
|
@Catch(RuntimeApprovalDeniedError)
|
||||||
|
export class RuntimeApprovalDeniedFilter implements ExceptionFilter {
|
||||||
|
catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void {
|
||||||
|
const response = host.switchToHttp().getResponse<{
|
||||||
|
status(code: number): { send(body: { statusCode: number; message: string }): void };
|
||||||
|
}>();
|
||||||
|
response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' });
|
||||||
|
}
|
||||||
|
}
|
||||||
500
apps/gateway/src/agent/runtime-provider-registry.service.ts
Normal file
500
apps/gateway/src/agent/runtime-provider-registry.service.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
|
import {
|
||||||
|
createRuntimeAuditLogEntry,
|
||||||
|
type LogService,
|
||||||
|
type RuntimeAuditErrorCode,
|
||||||
|
} from '@mosaicstack/log';
|
||||||
|
import type {
|
||||||
|
AgentRuntimeProvider,
|
||||||
|
RuntimeAttachHandle,
|
||||||
|
RuntimeAttachMode,
|
||||||
|
RuntimeCapability,
|
||||||
|
RuntimeCapabilitySet,
|
||||||
|
RuntimeHealth,
|
||||||
|
RuntimeMessage,
|
||||||
|
RuntimeScope,
|
||||||
|
RuntimeSession,
|
||||||
|
RuntimeSessionTree,
|
||||||
|
RuntimeStreamEvent,
|
||||||
|
TransitionalCapabilityInventoryEntry,
|
||||||
|
TransitionalCapabilityInventoryProvider,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
|
|
||||||
|
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
|
||||||
|
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
|
||||||
|
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
|
||||||
|
|
||||||
|
export type RuntimeProviderOperation =
|
||||||
|
| RuntimeCapability
|
||||||
|
| 'runtime.capabilities'
|
||||||
|
| 'runtime.health'
|
||||||
|
| 'runtime.transitional-capabilities';
|
||||||
|
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
||||||
|
|
||||||
|
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
||||||
|
export interface RuntimeProviderRequestContext {
|
||||||
|
actorScope: ActorTenantScope;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
|
||||||
|
export interface RuntimeAuditEvent {
|
||||||
|
providerId: string;
|
||||||
|
operation: RuntimeProviderOperation;
|
||||||
|
outcome: RuntimeProviderAuditOutcome;
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
resourceId?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
errorCode?: RuntimeAuditErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeAuditSink {
|
||||||
|
record(event: RuntimeAuditEvent): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact action shape that a durable approval implementation must consume once. */
|
||||||
|
export interface RuntimeTerminationAction {
|
||||||
|
providerId: string;
|
||||||
|
sessionId: string;
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
agentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeApprovalVerifier {
|
||||||
|
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTransitionalInventoryProvider(
|
||||||
|
provider: AgentRuntimeProvider,
|
||||||
|
): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider {
|
||||||
|
return (
|
||||||
|
typeof (provider as Partial<TransitionalCapabilityInventoryProvider>)
|
||||||
|
.transitionalCapabilityMatrix === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredAgentName(): string {
|
||||||
|
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
|
if (!agentName) throw new RuntimeApprovalDeniedError();
|
||||||
|
return agentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RuntimeApprovalDeniedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Runtime termination approval denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default denies all runtime termination until a durable, exact-action
|
||||||
|
* approval implementation is configured. This is safer than a permissive stub.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary metadata-only audit sink. M1 observability can replace this token
|
||||||
|
* with a durable audit writer without changing provider call sites.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RuntimeProviderAuditService implements RuntimeAuditSink {
|
||||||
|
private readonly logger = new Logger(RuntimeProviderAuditService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
|
||||||
|
|
||||||
|
async record(event: RuntimeAuditEvent): Promise<void> {
|
||||||
|
const entry = createRuntimeAuditLogEntry(event);
|
||||||
|
await this.logService.logs.ingest(entry);
|
||||||
|
this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RuntimeProviderService {
|
||||||
|
private readonly logger = new Logger(RuntimeProviderService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
|
||||||
|
private readonly registry: AgentRuntimeProviderRegistry,
|
||||||
|
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
|
private readonly audit: RuntimeAuditSink,
|
||||||
|
@Inject(RUNTIME_APPROVAL_VERIFIER)
|
||||||
|
private readonly approvals: RuntimeApprovalVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async capabilities(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeCapabilitySet> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'runtime.capabilities',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
|
||||||
|
provider.capabilities(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'runtime.health',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
|
||||||
|
provider.health(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transitionalCapabilityMatrix(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<TransitionalCapabilityInventoryEntry[]> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'runtime.transitional-capabilities',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
async (provider: AgentRuntimeProvider, scope: RuntimeScope) => {
|
||||||
|
if (!isTransitionalInventoryProvider(provider)) {
|
||||||
|
throw new NotFoundException('Runtime provider has no transitional capability inventory');
|
||||||
|
}
|
||||||
|
return provider.transitionalCapabilityMatrix(scope);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeSession[]> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.list',
|
||||||
|
'session.list',
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
|
||||||
|
provider.listSessions(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionTree(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeSessionTree[]> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.tree',
|
||||||
|
'session.tree',
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
|
||||||
|
provider.getSessionTree(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamSession(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
cursor: string | undefined,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
return this.stream(
|
||||||
|
providerId,
|
||||||
|
'session.stream',
|
||||||
|
'session.stream',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
|
||||||
|
provider.streamSession(sessionId, cursor, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
message: RuntimeMessage,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.send',
|
||||||
|
'session.send',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
||||||
|
provider.sendMessage(sessionId, message, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
mode: RuntimeAttachMode,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.attach',
|
||||||
|
'session.attach',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
|
||||||
|
provider.attach(sessionId, mode, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(
|
||||||
|
providerId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.attach',
|
||||||
|
'session.attach',
|
||||||
|
attachmentId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
||||||
|
provider.detach(attachmentId, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
approvalRef: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.terminate',
|
||||||
|
'session.terminate',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
|
||||||
|
const approved = await this.approvals.consume(approvalRef, {
|
||||||
|
providerId,
|
||||||
|
sessionId,
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
agentName: configuredAgentName(),
|
||||||
|
});
|
||||||
|
if (!approved) {
|
||||||
|
throw new RuntimeApprovalDeniedError();
|
||||||
|
}
|
||||||
|
await provider.terminate(sessionId, approvalRef, scope);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execute<T>(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
requiredCapability: RuntimeCapability | undefined,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const scope = this.deriveScope(context);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||||
|
let invocationStarted = false;
|
||||||
|
try {
|
||||||
|
const provider = this.provider(providerId);
|
||||||
|
if (requiredCapability) {
|
||||||
|
await this.assertCapability(provider, requiredCapability, scope);
|
||||||
|
}
|
||||||
|
invocationStarted = true;
|
||||||
|
const result = await invoke(provider, scope);
|
||||||
|
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
|
||||||
|
return result;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
if (invocationStarted && !this.isAuthorizationDenied(error)) {
|
||||||
|
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
||||||
|
} else {
|
||||||
|
await this.record(
|
||||||
|
providerId,
|
||||||
|
operation,
|
||||||
|
'denied',
|
||||||
|
scope,
|
||||||
|
resourceId,
|
||||||
|
durationMs,
|
||||||
|
'policy_denied',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *stream(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
requiredCapability: RuntimeCapability,
|
||||||
|
resourceId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
invoke: (
|
||||||
|
provider: AgentRuntimeProvider,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
) => AsyncIterable<RuntimeStreamEvent>,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
const scope = this.deriveScope(context);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||||
|
let invocationStarted = false;
|
||||||
|
try {
|
||||||
|
const provider = this.provider(providerId);
|
||||||
|
await this.assertCapability(provider, requiredCapability, scope);
|
||||||
|
invocationStarted = true;
|
||||||
|
for await (const event of invoke(provider, scope)) {
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
if (invocationStarted) {
|
||||||
|
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
||||||
|
} else {
|
||||||
|
await this.record(
|
||||||
|
providerId,
|
||||||
|
operation,
|
||||||
|
'denied',
|
||||||
|
scope,
|
||||||
|
resourceId,
|
||||||
|
durationMs,
|
||||||
|
'policy_denied',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAuthorizationDenied(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof RuntimeApprovalDeniedError ||
|
||||||
|
error instanceof ForbiddenException ||
|
||||||
|
(typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: unknown }).code === 'forbidden')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private provider(providerId: string): AgentRuntimeProvider {
|
||||||
|
try {
|
||||||
|
return this.registry.require(providerId);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
|
||||||
|
throw new NotFoundException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCapability(
|
||||||
|
provider: AgentRuntimeProvider,
|
||||||
|
requiredCapability: RuntimeCapability,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<void> {
|
||||||
|
const capabilities = await provider.capabilities(scope);
|
||||||
|
if (!capabilities.supported.includes(requiredCapability)) {
|
||||||
|
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
|
||||||
|
const actorId = context.actorScope.userId.trim();
|
||||||
|
const tenantId = context.actorScope.tenantId.trim();
|
||||||
|
const channelId = context.channelId.trim();
|
||||||
|
const correlationId = context.correlationId.trim();
|
||||||
|
if (!actorId || !tenantId || !channelId || !correlationId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Authenticated runtime actor scope and correlation are required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({ actorId, tenantId, channelId, correlationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordFailure(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
durationMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.record(
|
||||||
|
providerId,
|
||||||
|
operation,
|
||||||
|
'failed',
|
||||||
|
scope,
|
||||||
|
resourceId,
|
||||||
|
durationMs,
|
||||||
|
'provider_error',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.logger.error(
|
||||||
|
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordCompletion(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
durationMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs);
|
||||||
|
} catch {
|
||||||
|
this.logger.error(
|
||||||
|
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async record(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
outcome: RuntimeProviderAuditOutcome,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
durationMs?: number,
|
||||||
|
errorCode?: RuntimeAuditErrorCode,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.audit.record({
|
||||||
|
providerId,
|
||||||
|
operation,
|
||||||
|
outcome,
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
...(resourceId ? { resourceId } : {}),
|
||||||
|
...(durationMs !== undefined ? { durationMs } : {}),
|
||||||
|
...(errorCode ? { errorCode } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
|
|
||||||
@Controller('api/sessions')
|
@Controller('api/sessions')
|
||||||
@@ -18,23 +20,24 @@ export class SessionsController {
|
|||||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list() {
|
list(@CurrentUser() user: AuthenticatedUserLike) {
|
||||||
const sessions = this.agentService.listSessions();
|
const sessions = this.agentService.listSessions(scopeFromUser(user));
|
||||||
return { sessions, total: sessions.length };
|
return { sessions, total: sessions.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||||
const info = this.agentService.getSessionInfo(id);
|
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
|
||||||
if (!info) throw new NotFoundException('Session not found');
|
if (!info) throw new NotFoundException('Session not found');
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async destroy(@Param('id') id: string) {
|
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||||
const info = this.agentService.getSessionInfo(id);
|
const scope = scopeFromUser(user);
|
||||||
|
const info = this.agentService.getSessionInfo(id, scope);
|
||||||
if (!info) throw new NotFoundException('Session not found');
|
if (!info) throw new NotFoundException('Session not found');
|
||||||
await this.agentService.destroySession(id);
|
await this.agentService.destroySession(id, scope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
apps/gateway/src/agent/tools/memory-tools.test.ts
Normal file
41
apps/gateway/src/agent/tools/memory-tools.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createMemoryTools } from './memory-tools.js';
|
||||||
|
|
||||||
|
describe('createMemoryTools operator retrieval binding', () => {
|
||||||
|
const memory = {
|
||||||
|
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
|
||||||
|
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
|
||||||
|
};
|
||||||
|
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
|
||||||
|
|
||||||
|
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
|
||||||
|
const plugin = {
|
||||||
|
search: vi.fn(async () => []),
|
||||||
|
capture: vi.fn(async () => ({ id: 'insight-1' })),
|
||||||
|
};
|
||||||
|
const tools = createMemoryTools(memory as never, null, 'owner-a', {
|
||||||
|
plugin: plugin as never,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tools
|
||||||
|
.find((tool) => tool.name === 'memory_search')!
|
||||||
|
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
|
||||||
|
await tools
|
||||||
|
.find((tool) => tool.name === 'memory_save_insight')!
|
||||||
|
.execute(
|
||||||
|
'call-2',
|
||||||
|
{ content: 'secret', category: 'decision' },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
|
||||||
|
expect(plugin.capture).toHaveBeenCalledWith(scope, {
|
||||||
|
content: 'secret',
|
||||||
|
source: 'agent',
|
||||||
|
category: 'decision',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Memory } from '@mosaicstack/memory';
|
import type {
|
||||||
import type { EmbeddingProvider } from '@mosaicstack/memory';
|
EmbeddingProvider,
|
||||||
|
Memory,
|
||||||
|
OperatorMemoryPlugin,
|
||||||
|
OperatorMemoryScope,
|
||||||
|
} from '@mosaicstack/memory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create memory tools bound to the session's authenticated userId.
|
* Create memory tools bound to the session's authenticated userId.
|
||||||
@@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaicstack/memory';
|
|||||||
export function createMemoryTools(
|
export function createMemoryTools(
|
||||||
memory: Memory,
|
memory: Memory,
|
||||||
embeddingProvider: EmbeddingProvider | null,
|
embeddingProvider: EmbeddingProvider | null,
|
||||||
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
|
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
|
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
|
||||||
|
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
/** Return an error result when no session user is bound. */
|
/** Return an error result when no session user is bound. */
|
||||||
function noUserError() {
|
function noUserError() {
|
||||||
@@ -46,6 +52,14 @@ export function createMemoryTools(
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (operatorMemory) {
|
||||||
|
const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!embeddingProvider) {
|
if (!embeddingProvider) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -158,6 +172,18 @@ export function createMemoryTools(
|
|||||||
};
|
};
|
||||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||||
|
|
||||||
|
if (operatorMemory) {
|
||||||
|
const insight = await operatorMemory.plugin.capture(operatorMemory.scope, {
|
||||||
|
content,
|
||||||
|
source: 'agent',
|
||||||
|
category: category ?? 'learning',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let embedding: number[] | null = null;
|
let embedding: number[] | null = null;
|
||||||
if (embeddingProvider) {
|
if (embeddingProvider) {
|
||||||
embedding = await embeddingProvider.embed(content);
|
embedding = await embeddingProvider.embed(content);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { GCModule } from './gc/gc.module.js';
|
|||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -52,6 +53,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
FederationModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
24
apps/gateway/src/auth/session-scope.ts
Normal file
24
apps/gateway/src/auth/session-scope.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export interface AuthenticatedUserLike {
|
||||||
|
id: string;
|
||||||
|
tenantId?: string | null;
|
||||||
|
teamId?: string | null;
|
||||||
|
organizationId?: string | null;
|
||||||
|
orgId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActorTenantScope {
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the immutable server-derived scope used for Tess session operations.
|
||||||
|
* Current Mosaic auth is user-scoped; future org/team claims can populate one
|
||||||
|
* of the tenant fields without allowing clients to choose another tenant.
|
||||||
|
*/
|
||||||
|
export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope {
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ describe('Chat controller source hardening', () => {
|
|||||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||||
|
|
||||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
expect(source).toContain('@UseGuards(AuthGuard)');
|
||||||
expect(source).toContain('@CurrentUser() user: { id: string }');
|
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
|
||||||
|
expect(source).toContain('const scope = scopeFromUser(user);');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
Logger,
|
Logger,
|
||||||
|
ForbiddenException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
Inject,
|
Inject,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler';
|
|||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatRequestDto } from './chat.dto.js';
|
import { ChatRequestDto } from './chat.dto.js';
|
||||||
|
|
||||||
@@ -32,16 +35,23 @@ export class ChatController {
|
|||||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
async chat(
|
async chat(
|
||||||
@Body() body: ChatRequestDto,
|
@Body() body: ChatRequestDto,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
const conversationId = body.conversationId ?? uuid();
|
const conversationId = body.conversationId ?? uuid();
|
||||||
|
const scope = scopeFromUser(user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId);
|
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
agentSession = await this.agentService.createSession(conversationId);
|
agentSession = await this.agentService.createSession(conversationId, {
|
||||||
|
userId: scope.userId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof ForbiddenException) {
|
||||||
|
throw new NotFoundException('Session not found');
|
||||||
|
}
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Session creation failed for conversation=${conversationId}`,
|
`Session creation failed for conversation=${conversationId}`,
|
||||||
err instanceof Error ? err.stack : String(err),
|
err instanceof Error ? err.stack : String(err),
|
||||||
@@ -60,20 +70,27 @@ export class ChatController {
|
|||||||
reject(new Error('Agent response timed out'));
|
reject(new Error('Agent response timed out'));
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
const cleanup = this.agentService.onEvent(
|
||||||
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
conversationId,
|
||||||
responseText += event.assistantMessageEvent.delta;
|
(event: AgentSessionEvent) => {
|
||||||
}
|
if (
|
||||||
if (event.type === 'agent_end') {
|
event.type === 'message_update' &&
|
||||||
clearTimeout(timer);
|
event.assistantMessageEvent.type === 'text_delta'
|
||||||
cleanup();
|
) {
|
||||||
resolve();
|
responseText += event.assistantMessageEvent.delta;
|
||||||
}
|
}
|
||||||
});
|
if (event.type === 'agent_end') {
|
||||||
|
clearTimeout(timer);
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scope,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, body.content);
|
await this.agentService.prompt(conversationId, body.content, scope);
|
||||||
await done;
|
await done;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpException) throw err;
|
if (err instanceof HttpException) throw err;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import type { IncomingHttpHeaders } from 'node:http';
|
import type { IncomingHttpHeaders } from 'node:http';
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
|
|
||||||
@@ -12,6 +13,19 @@ export interface SessionAuth {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateDiscordServiceToken(
|
||||||
|
candidate: unknown,
|
||||||
|
expected: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (typeof candidate !== 'string' || !expected) return false;
|
||||||
|
const candidateBuffer = Buffer.from(candidate);
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
return (
|
||||||
|
candidateBuffer.length === expectedBuffer.length &&
|
||||||
|
timingSafeEqual(candidateBuffer, expectedBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateSocketSession(
|
export async function validateSocketSession(
|
||||||
headers: IncomingHttpHeaders,
|
headers: IncomingHttpHeaders,
|
||||||
auth: SessionAuth,
|
auth: SessionAuth,
|
||||||
|
|||||||
74
apps/gateway/src/chat/chat.gateway-command-approval.spec.ts
Normal file
74
apps/gateway/src/chat/chat.gateway-command-approval.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'gc',
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
approvalId: 'approval-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildGateway(commandExecutor: {
|
||||||
|
execute: ReturnType<typeof vi.fn>;
|
||||||
|
createApproval: ReturnType<typeof vi.fn>;
|
||||||
|
}): ChatGateway {
|
||||||
|
return new ChatGateway(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
commandExecutor as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatGateway command approval ingress', () => {
|
||||||
|
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
|
||||||
|
createApproval: vi.fn(),
|
||||||
|
};
|
||||||
|
const gateway = buildGateway(commandExecutor);
|
||||||
|
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||||
|
|
||||||
|
await gateway.handleCommandExecute(client as never, payload);
|
||||||
|
|
||||||
|
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
|
||||||
|
userId: 'admin-1',
|
||||||
|
tenantId: 'admin-1',
|
||||||
|
});
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'command:result',
|
||||||
|
expect.objectContaining({ success: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi.fn(),
|
||||||
|
createApproval: vi.fn().mockResolvedValue({
|
||||||
|
approvalId: 'approval-1',
|
||||||
|
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const gateway = buildGateway(commandExecutor);
|
||||||
|
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||||
|
|
||||||
|
await gateway.handleCommandApproval(client as never, {
|
||||||
|
command: 'gc',
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
||||||
|
{ command: 'gc', conversationId: 'conversation-1' },
|
||||||
|
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('command:approval', {
|
||||||
|
command: 'gc',
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'approval-1',
|
||||||
|
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
170
apps/gateway/src/chat/chat.gateway-redaction.spec.ts
Normal file
170
apps/gateway/src/chat/chat.gateway-redaction.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
|
|
||||||
|
const CONVERSATION_ID = 'conversation-1';
|
||||||
|
const CANARY = 'sk_canary12345678';
|
||||||
|
|
||||||
|
type GatewayInternals = {
|
||||||
|
clientSessions: Map<string, unknown>;
|
||||||
|
relayEvent(client: unknown, conversationId: string, event: unknown): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildGateway() {
|
||||||
|
const brain = {
|
||||||
|
conversations: {
|
||||||
|
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
};
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
agentService as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { gateway: gateway as unknown as GatewayInternals, brain };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatGateway redaction boundary', (): void => {
|
||||||
|
it('redacts a secret split across assistant deltas before egress and persistence', (): void => {
|
||||||
|
const { gateway } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
assistantText: '',
|
||||||
|
toolCalls: [],
|
||||||
|
pendingToolCalls: new Map(),
|
||||||
|
scope: { userId: 'user-1', tenantId: 'tenant-1' },
|
||||||
|
};
|
||||||
|
gateway.clientSessions.set(client.id, session);
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary');
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
text: '[REDACTED_SECRET] ',
|
||||||
|
});
|
||||||
|
expect(session.assistantText).toBe(`${CANARY} `);
|
||||||
|
expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains a split secret label until its value can be redacted', (): void => {
|
||||||
|
const { gateway } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: 'token ' },
|
||||||
|
});
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
text: '[REDACTED_SECRET] ',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a streamed private key until it can be redacted', (): void => {
|
||||||
|
const { gateway } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' },
|
||||||
|
});
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
text: '[REDACTED_SECRET]',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an oversized unterminated stream fragment rather than retaining it', (): void => {
|
||||||
|
const { gateway } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
text: '[REDACTED_STREAM_OVERFLOW]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists only redacted assistant content with classifications', (): void => {
|
||||||
|
const { gateway, brain } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
gateway.clientSessions.set(client.id, {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
assistantText: CANARY,
|
||||||
|
toolCalls: [],
|
||||||
|
pendingToolCalls: new Map(),
|
||||||
|
scope: { userId: 'user-1', tenantId: 'tenant-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' });
|
||||||
|
|
||||||
|
expect(brain.conversations.addMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: '[REDACTED_SECRET]',
|
||||||
|
metadata: expect.objectContaining({ classifications: ['secret'] }),
|
||||||
|
}),
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Inject, Logger } from '@nestjs/common';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { Inject, Logger, Optional } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
@@ -11,24 +12,47 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
|
import {
|
||||||
|
verifyDiscordIngressEnvelope,
|
||||||
|
parseDiscordInteractionBindings,
|
||||||
|
resolveDiscordInteractionActorId,
|
||||||
|
resolveDiscordInteractionBinding,
|
||||||
|
type DiscordIngressEnvelope,
|
||||||
|
type DiscordIngressPayload,
|
||||||
|
} from '@mosaicstack/discord-plugin';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaicstack/auth';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
|
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||||
import type {
|
import type {
|
||||||
SetThinkingPayload,
|
SetThinkingPayload,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
SlashCommandPayload,
|
SlashCommandPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
RoutingDecisionInfo,
|
RoutingDecisionInfo,
|
||||||
AbortPayload,
|
AbortPayload,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaicstack/types';
|
||||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||||
|
import {
|
||||||
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
|
RuntimeProviderService,
|
||||||
|
type RuntimeAuditSink,
|
||||||
|
} from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import { DurableSessionService } from '../agent/durable-session.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
import {
|
||||||
|
scopeFromUser,
|
||||||
|
type ActorTenantScope,
|
||||||
|
type AuthenticatedUserLike,
|
||||||
|
} from '../auth/session-scope.js';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||||
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js';
|
||||||
|
import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
|
||||||
|
|
||||||
/** Per-client state tracking streaming accumulation for persistence. */
|
/** Per-client state tracking streaming accumulation for persistence. */
|
||||||
interface ClientSession {
|
interface ClientSession {
|
||||||
@@ -40,6 +64,8 @@ interface ClientSession {
|
|||||||
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
||||||
/** Tool calls in-flight (started but not ended yet). */
|
/** Tool calls in-flight (started but not ended yet). */
|
||||||
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
||||||
|
/** Server-derived owner/tenant scope for this socket's conversation attachment. */
|
||||||
|
scope: ActorTenantScope;
|
||||||
/** Last routing decision made for this session (M4-008) */
|
/** Last routing decision made for this session (M4-008) */
|
||||||
lastRoutingDecision?: RoutingDecisionInfo;
|
lastRoutingDecision?: RoutingDecisionInfo;
|
||||||
}
|
}
|
||||||
@@ -49,6 +75,38 @@ interface ClientSession {
|
|||||||
* Keyed by conversationId, value is the model name to use.
|
* Keyed by conversationId, value is the model name to use.
|
||||||
*/
|
*/
|
||||||
const modelOverrides = new Map<string, string>();
|
const modelOverrides = new Map<string, string>();
|
||||||
|
const MAX_REDACTION_BUFFER_LENGTH = 8_192;
|
||||||
|
|
||||||
|
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const envelope = value as { payload?: unknown; signature?: unknown };
|
||||||
|
if (
|
||||||
|
typeof envelope.signature !== 'string' ||
|
||||||
|
typeof envelope.payload !== 'object' ||
|
||||||
|
envelope.payload === null
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const payload = envelope.payload as Record<string, unknown>;
|
||||||
|
return [
|
||||||
|
payload['correlationId'],
|
||||||
|
payload['messageId'],
|
||||||
|
payload['guildId'],
|
||||||
|
payload['channelId'],
|
||||||
|
payload['userId'],
|
||||||
|
payload['conversationId'],
|
||||||
|
payload['content'],
|
||||||
|
].every((field: unknown): boolean => typeof field === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const payload = value as { content?: unknown; conversationId?: unknown };
|
||||||
|
return (
|
||||||
|
typeof payload.content === 'string' &&
|
||||||
|
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
@@ -62,6 +120,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
private readonly logger = new Logger(ChatGateway.name);
|
private readonly logger = new Logger(ChatGateway.name);
|
||||||
private readonly clientSessions = new Map<string, ClientSession>();
|
private readonly clientSessions = new Map<string, ClientSession>();
|
||||||
|
/** Raw stream fragments are kept in memory only until they are safe to redact and emit. */
|
||||||
|
private readonly textEgressBuffers = new Map<string, string>();
|
||||||
|
private readonly thinkingEgressBuffers = new Map<string, string>();
|
||||||
|
private readonly overflowedEgress = new Set<string>();
|
||||||
|
private readonly discordReplayProtector = new DiscordReplayProtector();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@@ -70,6 +133,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||||
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(CommandAuthorizationService)
|
||||||
|
private readonly commandAuthorization: CommandAuthorizationService | null = null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(RuntimeProviderService)
|
||||||
|
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(DurableSessionService)
|
||||||
|
private readonly durableSessions: DurableSessionService | null = null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
|
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(): void {
|
afterInit(): void {
|
||||||
@@ -77,6 +152,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleConnection(client: Socket): Promise<void> {
|
async handleConnection(client: Socket): Promise<void> {
|
||||||
|
const serviceToken = client.handshake.auth['discordServiceToken'];
|
||||||
|
if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) {
|
||||||
|
client.data.discordService = true;
|
||||||
|
this.logger.log(`Authenticated Discord service connected: ${client.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
||||||
@@ -87,8 +169,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.data.user = session.user;
|
client.data.user = session.user;
|
||||||
client.data.session = session.session;
|
client.data.session = session.session;
|
||||||
this.logger.log(`Client connected: ${client.id}`);
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
|
|
||||||
// Broadcast command manifest to the newly connected client
|
|
||||||
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,25 +177,84 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const session = this.clientSessions.get(client.id);
|
const session = this.clientSessions.get(client.id);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.cleanup();
|
session.cleanup();
|
||||||
this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`);
|
this.agentService.removeChannel(
|
||||||
|
session.conversationId,
|
||||||
|
`websocket:${client.id}`,
|
||||||
|
session.scope,
|
||||||
|
);
|
||||||
this.clientSessions.delete(client.id);
|
this.clientSessions.delete(client.id);
|
||||||
}
|
}
|
||||||
|
this.textEgressBuffers.delete(client.id);
|
||||||
|
this.thinkingEgressBuffers.delete(client.id);
|
||||||
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
||||||
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClientScope(client: Socket): ActorTenantScope | null {
|
||||||
|
const user = client.data.user as AuthenticatedUserLike | undefined;
|
||||||
|
if (!user?.id) return null;
|
||||||
|
return scopeFromUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string {
|
||||||
|
return `${scope.tenantId}:${scope.userId}:${conversationId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean {
|
||||||
|
return a.userId === b.userId && a.tenantId === b.tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('message')
|
@SubscribeMessage('message')
|
||||||
async handleMessage(
|
async handleMessage(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() data: ChatSocketMessageDto,
|
@MessageBody() rawData: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
let discordIngress: DiscordIngressPayload | null = null;
|
||||||
|
let data: ChatSocketMessageDto;
|
||||||
|
if (client.data.discordService) {
|
||||||
|
if (!isDiscordIngressEnvelope(rawData)) {
|
||||||
|
this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
discordIngress = this.resolveDiscordIngress(client, rawData);
|
||||||
|
if (!discordIngress) return;
|
||||||
|
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
|
||||||
|
} else {
|
||||||
|
if (!isChatSocketMessage(rawData)) {
|
||||||
|
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = rawData;
|
||||||
|
}
|
||||||
const conversationId = data.conversationId ?? uuid();
|
const conversationId = data.conversationId ?? uuid();
|
||||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
||||||
|
if (discordIngress && !discordServiceUserId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord ingress without configured service owner from ${client.id}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scope = discordIngress
|
||||||
|
? {
|
||||||
|
userId: discordServiceUserId!,
|
||||||
|
tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!,
|
||||||
|
}
|
||||||
|
: this.getClientScope(client);
|
||||||
|
if (!scope) {
|
||||||
|
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = scope.userId;
|
||||||
|
const correlationId = discordIngress?.correlationId;
|
||||||
|
|
||||||
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
this.logger.log(
|
||||||
|
`Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Ensure agent session exists for this conversation
|
// Ensure agent session exists for this conversation
|
||||||
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId);
|
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
||||||
@@ -135,7 +274,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
let resolvedProvider = data.provider;
|
let resolvedProvider = data.provider;
|
||||||
let resolvedModelId = data.modelId;
|
let resolvedModelId = data.modelId;
|
||||||
|
|
||||||
const modelOverride = modelOverrides.get(conversationId);
|
const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||||
if (modelOverride) {
|
if (modelOverride) {
|
||||||
// /model override bypasses routing engine (M4-007)
|
// /model override bypasses routing engine (M4-007)
|
||||||
resolvedModelId = modelOverride;
|
resolvedModelId = modelOverride;
|
||||||
@@ -172,6 +311,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
modelId: resolvedModelId,
|
modelId: resolvedModelId,
|
||||||
agentConfigId: data.agentId,
|
agentConfigId: data.agentId,
|
||||||
userId,
|
userId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,9 +350,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: data.content,
|
content: redactSensitiveContent(data.content).content,
|
||||||
metadata: {
|
metadata: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
...(correlationId
|
||||||
|
? {
|
||||||
|
correlationId,
|
||||||
|
discordMessageId: discordIngress?.messageId,
|
||||||
|
discordUserId: discordIngress?.userId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
classifications: redactSensitiveContent(data.content).classifications,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
@@ -232,9 +380,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to agent events and relay to client
|
// Subscribe to agent events and relay to client
|
||||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
const cleanup = this.agentService.onEvent(
|
||||||
this.relayEvent(client, conversationId, event);
|
conversationId,
|
||||||
});
|
(event: AgentSessionEvent) => {
|
||||||
|
this.relayEvent(client, conversationId, event);
|
||||||
|
},
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
// Preserve routing decision from the existing client session if we didn't get a new one
|
// Preserve routing decision from the existing client session if we didn't get a new one
|
||||||
const prevClientSession = this.clientSessions.get(client.id);
|
const prevClientSession = this.clientSessions.get(client.id);
|
||||||
@@ -246,16 +398,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
assistantText: '',
|
assistantText: '',
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
pendingToolCalls: new Map(),
|
pendingToolCalls: new Map(),
|
||||||
|
scope,
|
||||||
lastRoutingDecision: routingDecisionToStore,
|
lastRoutingDecision: routingDecisionToStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track channel connection
|
// Track channel connection
|
||||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope);
|
||||||
|
|
||||||
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
||||||
// Include agentName when a named agent config is active (M5-001)
|
// Include agentName when a named agent config is active (M5-001)
|
||||||
{
|
{
|
||||||
const agentSession = this.agentService.getSession(conversationId);
|
const agentSession = this.agentService.getSession(conversationId, scope);
|
||||||
if (agentSession) {
|
if (agentSession) {
|
||||||
const piSession = agentSession.piSession;
|
const piSession = agentSession.piSession;
|
||||||
client.emit('session:info', {
|
client.emit('session:info', {
|
||||||
@@ -271,11 +424,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send acknowledgment
|
// Send acknowledgment
|
||||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
client.emit('message:ack', {
|
||||||
|
conversationId,
|
||||||
|
messageId: uuid(),
|
||||||
|
...(correlationId
|
||||||
|
? {
|
||||||
|
correlationId,
|
||||||
|
discordMessageId: discordIngress?.messageId,
|
||||||
|
discordUserId: discordIngress?.userId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
// Dispatch to agent
|
// Dispatch to agent
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, data.content);
|
await this.agentService.prompt(conversationId, data.content, scope);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
||||||
@@ -293,7 +456,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() data: SetThinkingPayload,
|
@MessageBody() data: SetThinkingPayload,
|
||||||
): void {
|
): void {
|
||||||
const session = this.agentService.getSession(data.conversationId);
|
const scope = this.getClientScope(client);
|
||||||
|
if (!scope) {
|
||||||
|
client.emit('error', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
error: 'Authenticated user scope is required.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.agentService.getSession(data.conversationId, scope);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
client.emit('error', {
|
client.emit('error', {
|
||||||
conversationId: data.conversationId,
|
conversationId: data.conversationId,
|
||||||
@@ -334,7 +506,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const conversationId = data.conversationId;
|
const conversationId = data.conversationId;
|
||||||
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
|
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
|
||||||
|
|
||||||
const session = this.agentService.getSession(conversationId);
|
const scope = this.getClientScope(client);
|
||||||
|
if (!scope) {
|
||||||
|
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.agentService.getSession(conversationId, scope);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
client.emit('error', {
|
client.emit('error', {
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -363,11 +541,45 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() payload: SlashCommandPayload,
|
@MessageBody() payload: SlashCommandPayload,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
const scope = this.getClientScope(client);
|
||||||
const result = await this.commandExecutor.execute(payload, userId);
|
if (!scope) {
|
||||||
|
client.emit('command:result', {
|
||||||
|
command: payload.command,
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
success: false,
|
||||||
|
message: 'Authenticated user scope is required.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.commandExecutor.execute(payload, scope);
|
||||||
client.emit('command:result', result);
|
client.emit('command:result', result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('command:approve')
|
||||||
|
async handleCommandApproval(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() payload: SlashCommandPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
const scope = this.getClientScope(client);
|
||||||
|
const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null;
|
||||||
|
const result: SlashCommandApprovalResultPayload = approval
|
||||||
|
? {
|
||||||
|
command: payload.command,
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
success: true,
|
||||||
|
approvalId: approval.approvalId,
|
||||||
|
expiresAt: approval.expiresAt,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
command: payload.command,
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized to approve this command.',
|
||||||
|
};
|
||||||
|
client.emit('command:approval', result);
|
||||||
|
}
|
||||||
|
|
||||||
broadcastReload(payload: SystemReloadPayload): void {
|
broadcastReload(payload: SystemReloadPayload): void {
|
||||||
this.server.emit('system:reload', payload);
|
this.server.emit('system:reload', payload);
|
||||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||||
@@ -380,18 +592,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
* M5-005: Emits session:info to clients subscribed to this conversation when a model is set.
|
* M5-005: Emits session:info to clients subscribed to this conversation when a model is set.
|
||||||
* M5-007: Records a model switch in session metrics.
|
* M5-007: Records a model switch in session metrics.
|
||||||
*/
|
*/
|
||||||
setModelOverride(conversationId: string, modelName: string | null): void {
|
setModelOverride(
|
||||||
|
conversationId: string,
|
||||||
|
modelName: string | null,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
): void {
|
||||||
|
const key = this.modelOverrideKey(conversationId, scope);
|
||||||
if (modelName) {
|
if (modelName) {
|
||||||
modelOverrides.set(conversationId, modelName);
|
modelOverrides.set(key, modelName);
|
||||||
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
|
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
|
||||||
|
|
||||||
// M5-002: Update the live session's modelId so session:info reflects the new model immediately
|
// M5-002: Update the live session's modelId so session:info reflects the new model immediately
|
||||||
this.agentService.updateSessionModel(conversationId, modelName);
|
this.agentService.updateSessionModel(conversationId, modelName, scope);
|
||||||
|
|
||||||
// M5-005: Broadcast session:info to all clients subscribed to this conversation
|
// M5-005: Broadcast session:info to all clients subscribed to this conversation
|
||||||
this.broadcastSessionInfo(conversationId);
|
this.broadcastSessionInfo(conversationId, scope);
|
||||||
} else {
|
} else {
|
||||||
modelOverrides.delete(conversationId);
|
modelOverrides.delete(key);
|
||||||
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,8 +616,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
/**
|
/**
|
||||||
* Return the active model override for a conversation, or undefined if none.
|
* Return the active model override for a conversation, or undefined if none.
|
||||||
*/
|
*/
|
||||||
getModelOverride(conversationId: string): string | undefined {
|
getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined {
|
||||||
return modelOverrides.get(conversationId);
|
return modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -409,9 +626,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
*/
|
*/
|
||||||
broadcastSessionInfo(
|
broadcastSessionInfo(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
||||||
): void {
|
): void {
|
||||||
const agentSession = this.agentService.getSession(conversationId);
|
const agentSession = this.agentService.getSession(conversationId, scope);
|
||||||
if (!agentSession) return;
|
if (!agentSession) return;
|
||||||
|
|
||||||
const piSession = agentSession.piSession;
|
const piSession = agentSession.piSession;
|
||||||
@@ -428,7 +646,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
// Emit to all clients currently subscribed to this conversation
|
// Emit to all clients currently subscribed to this conversation
|
||||||
for (const [clientId, session] of this.clientSessions) {
|
for (const [clientId, session] of this.clientSessions) {
|
||||||
if (session.conversationId === conversationId) {
|
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
|
||||||
const socket = this.server.sockets.sockets.get(clientId);
|
const socket = this.server.sockets.sockets.get(clientId);
|
||||||
if (socket?.connected) {
|
if (socket?.connected) {
|
||||||
socket.emit('session:info', payload);
|
socket.emit('session:info', payload);
|
||||||
@@ -442,6 +660,233 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||||
* would fail on the PK constraint and be caught here.
|
* would fail on the PK constraint and be caught here.
|
||||||
*/
|
*/
|
||||||
|
@SubscribeMessage('discord:approve')
|
||||||
|
async handleDiscordApproval(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() envelope: DiscordIngressEnvelope,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!client.data.discordService) return;
|
||||||
|
const ingress = this.resolveDiscordIngress(client, envelope, 'approve');
|
||||||
|
const isApprovalCommand = /^\/approve\s*$/i.test(ingress?.content ?? '');
|
||||||
|
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||||
|
if (
|
||||||
|
!ingress ||
|
||||||
|
!isApprovalCommand ||
|
||||||
|
!tenantId ||
|
||||||
|
!this.commandAuthorization ||
|
||||||
|
!this.durableSessions
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
ingress.guildId,
|
||||||
|
ingress.channelId,
|
||||||
|
ingress.userId,
|
||||||
|
'approve',
|
||||||
|
);
|
||||||
|
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||||
|
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
|
if (!actorId || !agentName || binding.instanceId !== agentName) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
|
||||||
|
);
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, {
|
||||||
|
actorScope: { userId: actorId, tenantId },
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (snapshot.identity.agentName !== agentName) {
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const approval = await this.commandAuthorization.createRuntimeTerminationApproval({
|
||||||
|
providerId: snapshot.identity.providerId,
|
||||||
|
sessionId: snapshot.identity.runtimeSessionId,
|
||||||
|
actorId,
|
||||||
|
tenantId,
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: this.discordRuntimeActionCorrelation(
|
||||||
|
binding.instanceId,
|
||||||
|
ingress,
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
),
|
||||||
|
agentName,
|
||||||
|
});
|
||||||
|
if (!approval) {
|
||||||
|
await this.runtimeAudit?.record({
|
||||||
|
providerId: snapshot.identity.providerId,
|
||||||
|
operation: 'session.terminate',
|
||||||
|
outcome: 'denied',
|
||||||
|
actorId,
|
||||||
|
tenantId,
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: this.discordRuntimeActionCorrelation(
|
||||||
|
binding.instanceId,
|
||||||
|
ingress,
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
),
|
||||||
|
resourceId: snapshot.identity.runtimeSessionId,
|
||||||
|
errorCode: 'policy_denied',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
client.emit('discord:approval', {
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
success: approval !== null,
|
||||||
|
approvalId: approval?.approvalId,
|
||||||
|
expiresAt: approval?.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('discord:stop')
|
||||||
|
async handleDiscordStop(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() envelope: DiscordIngressEnvelope,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!client.data.discordService) return;
|
||||||
|
const ingress = this.resolveDiscordIngress(client, envelope, 'stop');
|
||||||
|
const approvalRef = /^\/stop\s+([^\s]+)$/i.exec(ingress?.content ?? '')?.[1];
|
||||||
|
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||||
|
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
|
||||||
|
return;
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
ingress.guildId,
|
||||||
|
ingress.channelId,
|
||||||
|
ingress.userId,
|
||||||
|
'stop',
|
||||||
|
);
|
||||||
|
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||||
|
if (!actorId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = {
|
||||||
|
actorScope: { userId: actorId, tenantId },
|
||||||
|
channelId: ingress.channelId,
|
||||||
|
correlationId: ingress.correlationId,
|
||||||
|
};
|
||||||
|
const snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, context);
|
||||||
|
if (snapshot.identity.agentName !== binding.instanceId) throw new Error('agent mismatch');
|
||||||
|
// RuntimeProviderService consumes the durable approval exactly once using the
|
||||||
|
// provisioned approving-admin identity, never the Discord service account.
|
||||||
|
await this.runtimeRegistry.terminate(
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
approvalRef,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
correlationId: this.discordRuntimeActionCorrelation(
|
||||||
|
binding.instanceId,
|
||||||
|
ingress,
|
||||||
|
snapshot.identity.providerId,
|
||||||
|
snapshot.identity.runtimeSessionId,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
client.emit('discord:stop', { correlationId: ingress.correlationId, success: true });
|
||||||
|
} catch {
|
||||||
|
client.emit('discord:stop', { correlationId: ingress.correlationId, success: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlates the immutable termination target rather than either Discord message.
|
||||||
|
* Approval and stop are distinct ingress events, but must consume the same seven-field action.
|
||||||
|
*/
|
||||||
|
private discordRuntimeActionCorrelation(
|
||||||
|
instanceId: string,
|
||||||
|
ingress: DiscordIngressPayload,
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
): string {
|
||||||
|
const target = [
|
||||||
|
instanceId,
|
||||||
|
ingress.guildId,
|
||||||
|
ingress.channelId,
|
||||||
|
ingress.conversationId,
|
||||||
|
providerId,
|
||||||
|
sessionId,
|
||||||
|
];
|
||||||
|
return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDiscordIngress(
|
||||||
|
client: Socket,
|
||||||
|
envelope: DiscordIngressEnvelope,
|
||||||
|
operation: 'send' | 'approve' | 'stop' = 'send',
|
||||||
|
): DiscordIngressPayload | null {
|
||||||
|
const payload = verifyDiscordIngressEnvelope(
|
||||||
|
envelope,
|
||||||
|
process.env['DISCORD_SERVICE_TOKEN'] ?? '',
|
||||||
|
{
|
||||||
|
guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||||
|
channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||||
|
userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!payload) {
|
||||||
|
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
payload.guildId,
|
||||||
|
payload.channelId,
|
||||||
|
payload.userId,
|
||||||
|
operation,
|
||||||
|
);
|
||||||
|
if (!binding) {
|
||||||
|
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readDiscordAllowlist(name: string): string[] {
|
||||||
|
return (process.env[name] ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((id: string): string => id.trim())
|
||||||
|
.filter((id: string): boolean => id.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
|
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const existing = await this.brain.conversations.findById(conversationId, userId);
|
const existing = await this.brain.conversations.findById(conversationId, userId);
|
||||||
@@ -527,6 +972,127 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private appendAndFlushRedactedEgress(
|
||||||
|
client: Socket,
|
||||||
|
conversationId: string,
|
||||||
|
eventName: 'agent:text' | 'agent:thinking',
|
||||||
|
buffers: Map<string, string>,
|
||||||
|
delta: string,
|
||||||
|
): void {
|
||||||
|
const key = this.egressKey(client, eventName);
|
||||||
|
if (this.overflowedEgress.has(key)) return;
|
||||||
|
|
||||||
|
const buffered = `${buffers.get(client.id) ?? ''}${delta}`;
|
||||||
|
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
|
||||||
|
buffers.delete(client.id);
|
||||||
|
this.overflowedEgress.add(key);
|
||||||
|
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffers.set(client.id, buffered);
|
||||||
|
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds any suffix that could become a secret, email, or phone number after a
|
||||||
|
* later stream chunk. This avoids relying on downstream redaction after data
|
||||||
|
* has already reached the socket.
|
||||||
|
*/
|
||||||
|
private flushRedactedEgress(
|
||||||
|
client: Socket,
|
||||||
|
conversationId: string,
|
||||||
|
eventName: 'agent:text' | 'agent:thinking',
|
||||||
|
buffers: Map<string, string>,
|
||||||
|
final: boolean,
|
||||||
|
): void {
|
||||||
|
const key = this.egressKey(client, eventName);
|
||||||
|
if (this.overflowedEgress.has(key)) {
|
||||||
|
if (final) this.overflowedEgress.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffered = buffers.get(client.id) ?? '';
|
||||||
|
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
|
||||||
|
const released = buffered.slice(0, releaseLength);
|
||||||
|
const pending = buffered.slice(releaseLength);
|
||||||
|
|
||||||
|
if (pending) {
|
||||||
|
buffers.set(client.id, pending);
|
||||||
|
} else {
|
||||||
|
buffers.delete(client.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (released) {
|
||||||
|
client.emit(eventName, {
|
||||||
|
conversationId,
|
||||||
|
text: redactSensitiveContent(released).content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeRedactionPrefixLength(content: string): number {
|
||||||
|
let retainedFrom = content.length;
|
||||||
|
|
||||||
|
// Retain the current token because it may become a split secret or email.
|
||||||
|
const token = /(?:^|\s)(\S*)$/.exec(content);
|
||||||
|
if (token) {
|
||||||
|
const matched = token[0] ?? '';
|
||||||
|
const trailingToken = token[1] ?? '';
|
||||||
|
retainedFrom = token.index + matched.length - trailingToken.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The secret classifier accepts whitespace around ':' and '=', so preserve
|
||||||
|
// a pending label until its value and delimiter are both complete.
|
||||||
|
const pendingSecretLabel =
|
||||||
|
/(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec(
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
if (pendingSecretLabel) {
|
||||||
|
const label = pendingSecretLabel[1] ?? '';
|
||||||
|
retainedFrom = Math.min(
|
||||||
|
retainedFrom,
|
||||||
|
pendingSecretLabel.index + pendingSecretLabel[0].length - label.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec(
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
if (secretLabel) {
|
||||||
|
retainedFrom = Math.min(retainedFrom, secretLabel.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone numbers can contain whitespace and punctuation; preserve the full
|
||||||
|
// trailing numeric candidate until a non-phone character establishes a boundary.
|
||||||
|
const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content);
|
||||||
|
if (phone) {
|
||||||
|
const matched = phone[0] ?? '';
|
||||||
|
const trailingPhoneCandidate = phone[1] ?? '';
|
||||||
|
retainedFrom = Math.min(
|
||||||
|
retainedFrom,
|
||||||
|
phone.index + matched.length - trailingPhoneCandidate.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKeyStart = content.lastIndexOf('-----BEGIN');
|
||||||
|
if (privateKeyStart >= 0) {
|
||||||
|
const privateKey = content.slice(privateKeyStart);
|
||||||
|
if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) {
|
||||||
|
// Release the complete block in one pass so the full-block classifier can redact it.
|
||||||
|
retainedFrom = content.length;
|
||||||
|
} else {
|
||||||
|
retainedFrom = Math.min(retainedFrom, privateKeyStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return retainedFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string {
|
||||||
|
return `${client.id}:${eventName}`;
|
||||||
|
}
|
||||||
|
|
||||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||||
if (!client.connected) {
|
if (!client.connected) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -544,13 +1110,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
cs.toolCalls = [];
|
cs.toolCalls = [];
|
||||||
cs.pendingToolCalls.clear();
|
cs.pendingToolCalls.clear();
|
||||||
}
|
}
|
||||||
|
this.textEgressBuffers.set(client.id, '');
|
||||||
|
this.thinkingEgressBuffers.set(client.id, '');
|
||||||
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
||||||
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
|
||||||
client.emit('agent:start', { conversationId });
|
client.emit('agent:start', { conversationId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'agent_end': {
|
case 'agent_end': {
|
||||||
// Gather usage stats from the Pi session
|
// Gather usage stats from the Pi session
|
||||||
const agentSession = this.agentService.getSession(conversationId);
|
const activeClientSession = this.clientSessions.get(client.id);
|
||||||
|
const agentSession = activeClientSession
|
||||||
|
? this.agentService.getSession(conversationId, activeClientSession.scope)
|
||||||
|
: undefined;
|
||||||
const piSession = agentSession?.piSession;
|
const piSession = agentSession?.piSession;
|
||||||
const stats = piSession?.getSessionStats();
|
const stats = piSession?.getSessionStats();
|
||||||
const contextUsage = piSession?.getContextUsage();
|
const contextUsage = piSession?.getContextUsage();
|
||||||
@@ -569,6 +1142,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
this.flushRedactedEgress(
|
||||||
|
client,
|
||||||
|
conversationId,
|
||||||
|
'agent:text',
|
||||||
|
this.textEgressBuffers,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.flushRedactedEgress(
|
||||||
|
client,
|
||||||
|
conversationId,
|
||||||
|
'agent:thinking',
|
||||||
|
this.thinkingEgressBuffers,
|
||||||
|
true,
|
||||||
|
);
|
||||||
client.emit('agent:end', {
|
client.emit('agent:end', {
|
||||||
conversationId,
|
conversationId,
|
||||||
usage: usagePayload,
|
usage: usagePayload,
|
||||||
@@ -611,8 +1198,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: cs.assistantText,
|
content: redactSensitiveContent(cs.assistantText).content,
|
||||||
metadata,
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
classifications: redactSensitiveContent(cs.assistantText).classifications,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
)
|
)
|
||||||
@@ -634,20 +1224,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
case 'message_update': {
|
case 'message_update': {
|
||||||
const assistantEvent = event.assistantMessageEvent;
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
if (assistantEvent.type === 'text_delta') {
|
if (assistantEvent.type === 'text_delta') {
|
||||||
// Accumulate assistant text for persistence
|
// Keep raw stream material in memory only; persist and emit only redacted text.
|
||||||
const cs = this.clientSessions.get(client.id);
|
const cs = this.clientSessions.get(client.id);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.assistantText += assistantEvent.delta;
|
cs.assistantText += assistantEvent.delta;
|
||||||
}
|
}
|
||||||
client.emit('agent:text', {
|
this.appendAndFlushRedactedEgress(
|
||||||
|
client,
|
||||||
conversationId,
|
conversationId,
|
||||||
text: assistantEvent.delta,
|
'agent:text',
|
||||||
});
|
this.textEgressBuffers,
|
||||||
|
assistantEvent.delta,
|
||||||
|
);
|
||||||
} else if (assistantEvent.type === 'thinking_delta') {
|
} else if (assistantEvent.type === 'thinking_delta') {
|
||||||
client.emit('agent:thinking', {
|
this.appendAndFlushRedactedEgress(
|
||||||
|
client,
|
||||||
conversationId,
|
conversationId,
|
||||||
text: assistantEvent.delta,
|
'agent:thinking',
|
||||||
});
|
this.thinkingEgressBuffers,
|
||||||
|
assistantEvent.delta,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
116
apps/gateway/src/commands/command-authorization.service.spec.ts
Normal file
116
apps/gateway/src/commands/command-authorization.service.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
|
|
||||||
|
const adminCommand: CommandDef = {
|
||||||
|
name: 'gc',
|
||||||
|
description: 'GC',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'admin',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||||
|
|
||||||
|
function createService(
|
||||||
|
role: string,
|
||||||
|
entries: Map<string, string> = new Map<string, string>(),
|
||||||
|
): CommandAuthorizationService {
|
||||||
|
const db = {
|
||||||
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
get: async (key: string) => entries.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => {
|
||||||
|
entries.set(key, value);
|
||||||
|
},
|
||||||
|
del: async (key: string) => Number(entries.delete(key)),
|
||||||
|
};
|
||||||
|
return new CommandAuthorizationService(db as never, redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CommandAuthorizationService', () => {
|
||||||
|
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
|
||||||
|
const service = createService('admin');
|
||||||
|
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||||
|
expect(approval).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
|
||||||
|
const service = createService('admin');
|
||||||
|
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||||
|
const mutated = { ...payload, conversationId: 'other-conversation' };
|
||||||
|
expect(approval).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
|
||||||
|
const service = createService('member');
|
||||||
|
const approval = await service.createApproval(adminCommand, payload, 'member-1');
|
||||||
|
expect(approval).toBeNull();
|
||||||
|
expect(
|
||||||
|
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const action = {
|
||||||
|
providerId: 'fleet',
|
||||||
|
sessionId: 'nova',
|
||||||
|
actorId: 'admin-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
channelId: 'discord:operator',
|
||||||
|
correlationId: 'correlation-malformed-expiry',
|
||||||
|
agentName: 'Nova',
|
||||||
|
};
|
||||||
|
const service = createService('admin', entries);
|
||||||
|
const approval = await service.createRuntimeTerminationApproval(action);
|
||||||
|
expect(approval).not.toBeNull();
|
||||||
|
const key = `agent:Nova:command-approval:${approval!.approvalId}`;
|
||||||
|
const stored = entries.get(key);
|
||||||
|
expect(stored).toBeDefined();
|
||||||
|
entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' }));
|
||||||
|
|
||||||
|
expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise<void> => {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const action = {
|
||||||
|
providerId: 'fleet',
|
||||||
|
sessionId: 'nova',
|
||||||
|
actorId: 'admin-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
channelId: 'discord:operator',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
agentName: 'Nova',
|
||||||
|
};
|
||||||
|
const beforeRestart = createService('admin', entries);
|
||||||
|
const approval = await beforeRestart.createRuntimeTerminationApproval(action);
|
||||||
|
|
||||||
|
const afterRestart = createService('admin', entries);
|
||||||
|
expect(
|
||||||
|
await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, {
|
||||||
|
...action,
|
||||||
|
sessionId: 'forged-session',
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
268
apps/gateway/src/commands/command-authorization.service.ts
Normal file
268
apps/gateway/src/commands/command-authorization.service.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
|
||||||
|
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
|
||||||
|
export type CommandRole = 'admin' | 'member' | 'viewer';
|
||||||
|
|
||||||
|
export interface CommandApproval {
|
||||||
|
approvalId: string;
|
||||||
|
actionDigest: string;
|
||||||
|
actorId: string;
|
||||||
|
command: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact immutable binding for a privileged runtime termination. */
|
||||||
|
export interface RuntimeTerminationApprovalAction {
|
||||||
|
providerId: string;
|
||||||
|
sessionId: string;
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
/** Provisioned roster identity; isolates approvals between interaction agents. */
|
||||||
|
agentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
|
||||||
|
approvalId: string;
|
||||||
|
actionDigest: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandAuthorizationResult {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommandAuthorizationService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DB) private readonly db: Db,
|
||||||
|
@Inject(COMMANDS_REDIS)
|
||||||
|
private readonly redis: {
|
||||||
|
get(key: string): Promise<string | null>;
|
||||||
|
set(key: string, value: string, ...args: string[]): Promise<unknown>;
|
||||||
|
del(key: string): Promise<number>;
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async authorize(
|
||||||
|
command: CommandDef,
|
||||||
|
payload: SlashCommandPayload,
|
||||||
|
actorId: string,
|
||||||
|
approvalId?: string,
|
||||||
|
): Promise<CommandAuthorizationResult> {
|
||||||
|
const role = await this.resolveRole(actorId);
|
||||||
|
if (!role || !this.hasScope(role, command.scope)) {
|
||||||
|
return { allowed: false, reason: 'not authorized for this command scope' };
|
||||||
|
}
|
||||||
|
if (command.scope !== 'admin') return { allowed: true };
|
||||||
|
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
|
||||||
|
const actionDigest = this.actionDigest(command.name, payload);
|
||||||
|
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
|
||||||
|
return approved
|
||||||
|
? { allowed: true }
|
||||||
|
: {
|
||||||
|
allowed: false,
|
||||||
|
reason: 'approval is invalid, expired, replayed, or does not match this action',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createApproval(
|
||||||
|
command: CommandDef,
|
||||||
|
payload: SlashCommandPayload,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<CommandApproval | null> {
|
||||||
|
const role = await this.resolveRole(actorId);
|
||||||
|
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
|
||||||
|
|
||||||
|
const approvalId = randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||||
|
const approval: CommandApproval = {
|
||||||
|
approvalId,
|
||||||
|
actionDigest: this.actionDigest(command.name, payload),
|
||||||
|
actorId,
|
||||||
|
command: command.name,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
|
||||||
|
return approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
|
||||||
|
* command approvals. This deliberately avoids a parallel approval database.
|
||||||
|
*/
|
||||||
|
async createRuntimeTerminationApproval(
|
||||||
|
action: RuntimeTerminationApprovalAction,
|
||||||
|
): Promise<RuntimeTerminationApproval | null> {
|
||||||
|
if (!this.hasRuntimeTerminationAction(action)) return null;
|
||||||
|
const role = await this.resolveRole(action.actorId);
|
||||||
|
if (role !== 'admin') return null;
|
||||||
|
|
||||||
|
const approval: RuntimeTerminationApproval = {
|
||||||
|
approvalId: randomUUID(),
|
||||||
|
actionDigest: this.runtimeActionDigest(action),
|
||||||
|
...action,
|
||||||
|
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
||||||
|
};
|
||||||
|
await this.redis.set(
|
||||||
|
this.runtimeKey(action.agentName, approval.approvalId),
|
||||||
|
JSON.stringify(approval),
|
||||||
|
'EX',
|
||||||
|
'300',
|
||||||
|
);
|
||||||
|
return approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeRuntimeTerminationApproval(
|
||||||
|
approvalId: string,
|
||||||
|
action: RuntimeTerminationApprovalAction,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
|
||||||
|
if (!encoded) return false;
|
||||||
|
let approval: unknown;
|
||||||
|
try {
|
||||||
|
approval = JSON.parse(encoded);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!this.isRuntimeTerminationApproval(approval) ||
|
||||||
|
approval.actionDigest !== this.runtimeActionDigest(action) ||
|
||||||
|
approval.actorId !== action.actorId ||
|
||||||
|
approval.tenantId !== action.tenantId ||
|
||||||
|
!this.isUnexpired(approval.expiresAt)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
|
||||||
|
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
||||||
|
const [user] = await this.db
|
||||||
|
.select({ role: usersTable.role })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, actorId))
|
||||||
|
.limit(1);
|
||||||
|
const role = user?.role;
|
||||||
|
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
||||||
|
if (role === 'admin') return true;
|
||||||
|
return role === 'member' && (scope === 'core' || scope === 'agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumeApproval(
|
||||||
|
approvalId: string,
|
||||||
|
actorId: string,
|
||||||
|
actionDigest: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = this.key(approvalId);
|
||||||
|
const encoded = await this.redis.get(key);
|
||||||
|
if (!encoded) return false;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(encoded);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!this.isCommandApproval(parsed) ||
|
||||||
|
parsed.actorId !== actorId ||
|
||||||
|
parsed.actionDigest !== actionDigest ||
|
||||||
|
!this.isUnexpired(parsed.expiresAt)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return (await this.redis.del(key)) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private actionDigest(command: string, payload: SlashCommandPayload): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
command,
|
||||||
|
args: payload.args?.trim() ?? '',
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
|
||||||
|
return [
|
||||||
|
action.providerId,
|
||||||
|
action.sessionId,
|
||||||
|
action.actorId,
|
||||||
|
action.tenantId,
|
||||||
|
action.channelId,
|
||||||
|
action.correlationId,
|
||||||
|
action.agentName,
|
||||||
|
].every((value: string): boolean => value.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
providerId: action.providerId,
|
||||||
|
sessionId: action.sessionId,
|
||||||
|
actorId: action.actorId,
|
||||||
|
tenantId: action.tenantId,
|
||||||
|
channelId: action.channelId,
|
||||||
|
correlationId: action.correlationId,
|
||||||
|
agentName: action.agentName,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUnexpired(expiresAt: unknown): expiresAt is string {
|
||||||
|
if (typeof expiresAt !== 'string') return false;
|
||||||
|
const expiresAtMs = Date.parse(expiresAt);
|
||||||
|
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCommandApproval(value: unknown): value is CommandApproval {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'approvalId' in value &&
|
||||||
|
'actionDigest' in value &&
|
||||||
|
'actorId' in value &&
|
||||||
|
'expiresAt' in value &&
|
||||||
|
'command' in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'approvalId' in value &&
|
||||||
|
'actionDigest' in value &&
|
||||||
|
'actorId' in value &&
|
||||||
|
'tenantId' in value &&
|
||||||
|
'providerId' in value &&
|
||||||
|
'sessionId' in value &&
|
||||||
|
'channelId' in value &&
|
||||||
|
'correlationId' in value &&
|
||||||
|
'agentName' in value &&
|
||||||
|
'expiresAt' in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(approvalId: string): string {
|
||||||
|
return `interaction:command-approval:${approvalId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private runtimeKey(agentName: string, approvalId: string): string {
|
||||||
|
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ function buildService(): CommandExecutorService {
|
|||||||
describe('CommandExecutorService — P8-012 commands', () => {
|
describe('CommandExecutorService — P8-012 commands', () => {
|
||||||
let service: CommandExecutorService;
|
let service: CommandExecutorService;
|
||||||
const userId = 'user-123';
|
const userId = 'user-123';
|
||||||
|
const userScope = { userId, tenantId: userId };
|
||||||
const conversationId = 'conv-456';
|
const conversationId = 'conv-456';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -99,31 +100,26 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider login — missing provider name
|
// /provider login — missing provider name
|
||||||
it('/provider login with no provider name returns usage error', async () => {
|
it('/provider login with no provider name returns usage error', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Usage: /provider login');
|
expect(result.message).toContain('Usage: /provider login');
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
});
|
});
|
||||||
|
|
||||||
// /provider login anthropic — success with URL containing poll token
|
// /provider login anthropic — no bearer token or auth URL reaches chat output
|
||||||
it('/provider login <name> returns success with URL and poll token', async () => {
|
it('/provider login <name> keeps its one-time token out of chat output', async () => {
|
||||||
const payload: SlashCommandPayload = {
|
const payload: SlashCommandPayload = {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
args: 'login anthropic',
|
args: 'login anthropic',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
expect(result.message).toContain('anthropic');
|
expect(result.message).toContain('anthropic');
|
||||||
expect(result.message).toContain('http');
|
expect(result.message).not.toContain('http');
|
||||||
// data should contain loginUrl and pollToken
|
expect(result.message).not.toContain('token=');
|
||||||
expect(result.data).toBeDefined();
|
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||||
const data = result.data as Record<string, unknown>;
|
|
||||||
expect(typeof data['loginUrl']).toBe('string');
|
|
||||||
expect(typeof data['pollToken']).toBe('string');
|
|
||||||
expect(data['loginUrl'] as string).toContain('anthropic');
|
|
||||||
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
|
|
||||||
// Verify Valkey was called
|
// Verify Valkey was called
|
||||||
expect(mockRedis.set).toHaveBeenCalledOnce();
|
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||||
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||||
@@ -138,7 +134,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider with no args — returns usage
|
// /provider with no args — returns usage
|
||||||
it('/provider with no args returns usage message', async () => {
|
it('/provider with no args returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage: /provider');
|
expect(result.message).toContain('Usage: /provider');
|
||||||
});
|
});
|
||||||
@@ -146,7 +142,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider list
|
// /provider list
|
||||||
it('/provider list returns success', async () => {
|
it('/provider list returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
});
|
});
|
||||||
@@ -154,7 +150,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider logout with no name — usage error
|
// /provider logout with no name — usage error
|
||||||
it('/provider logout with no name returns error', async () => {
|
it('/provider logout with no name returns error', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Usage: /provider logout');
|
expect(result.message).toContain('Usage: /provider logout');
|
||||||
});
|
});
|
||||||
@@ -166,7 +162,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'unknown',
|
args: 'unknown',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Unknown subcommand');
|
expect(result.message).toContain('Unknown subcommand');
|
||||||
});
|
});
|
||||||
@@ -174,7 +170,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /mission status
|
// /mission status
|
||||||
it('/mission status returns stub message', async () => {
|
it('/mission status returns stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('mission');
|
expect(result.command).toBe('mission');
|
||||||
expect(result.message).toContain('Mission status');
|
expect(result.message).toContain('Mission status');
|
||||||
@@ -183,7 +179,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /mission with no args
|
// /mission with no args
|
||||||
it('/mission with no args returns status stub', async () => {
|
it('/mission with no args returns status stub', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Mission status');
|
expect(result.message).toContain('Mission status');
|
||||||
});
|
});
|
||||||
@@ -195,7 +191,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'set my-mission-123',
|
args: 'set my-mission-123',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('my-mission-123');
|
expect(result.message).toContain('my-mission-123');
|
||||||
});
|
});
|
||||||
@@ -203,7 +199,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /agent list
|
// /agent list
|
||||||
it('/agent list returns stub message', async () => {
|
it('/agent list returns stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('agent');
|
expect(result.command).toBe('agent');
|
||||||
expect(result.message).toContain('agent');
|
expect(result.message).toContain('agent');
|
||||||
@@ -212,7 +208,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /agent with no args
|
// /agent with no args
|
||||||
it('/agent with no args returns usage', async () => {
|
it('/agent with no args returns usage', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage: /agent');
|
expect(result.message).toContain('Usage: /agent');
|
||||||
});
|
});
|
||||||
@@ -224,7 +220,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'my-agent-id',
|
args: 'my-agent-id',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('my-agent-id');
|
expect(result.message).toContain('my-agent-id');
|
||||||
});
|
});
|
||||||
@@ -232,7 +228,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /prdy
|
// /prdy
|
||||||
it('/prdy returns PRD wizard message', async () => {
|
it('/prdy returns PRD wizard message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('prdy');
|
expect(result.command).toBe('prdy');
|
||||||
expect(result.message).toContain('mosaic prdy');
|
expect(result.message).toContain('mosaic prdy');
|
||||||
@@ -241,7 +237,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /tools
|
// /tools
|
||||||
it('/tools returns tools stub message', async () => {
|
it('/tools returns tools stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('tools');
|
expect(result.message).toContain('tools');
|
||||||
|
|||||||
112
apps/gateway/src/commands/command-executor-tess-security.spec.ts
Normal file
112
apps/gateway/src/commands/command-executor-tess-security.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
|
|
||||||
|
const registry = {
|
||||||
|
getManifest: vi.fn(() => ({
|
||||||
|
version: 1,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'gc',
|
||||||
|
description: 'System-wide garbage collection',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'admin' as const,
|
||||||
|
execution: 'socket' as const,
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionGc = {
|
||||||
|
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
|
||||||
|
|
||||||
|
const authorization = {
|
||||||
|
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
|
||||||
|
Promise.resolve(
|
||||||
|
actorId === 'member-1'
|
||||||
|
? { allowed: false, reason: 'durable approval is required' }
|
||||||
|
: { allowed: false, reason: 'not authorized for this command scope' },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||||
|
return new CommandExecutorService(
|
||||||
|
registry as never,
|
||||||
|
{ getSession: vi.fn() } as never,
|
||||||
|
{ clear: vi.fn(), set: vi.fn() } as never,
|
||||||
|
sessionGc as never,
|
||||||
|
{ set: vi.fn() } as never,
|
||||||
|
{ agents: {} } as never,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
authorizationService as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDurableAuthorization(): CommandAuthorizationService {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const db = {
|
||||||
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
get: async (key: string) => entries.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => {
|
||||||
|
entries.set(key, value);
|
||||||
|
},
|
||||||
|
del: async (key: string) => Number(entries.delete(key)),
|
||||||
|
};
|
||||||
|
return new CommandAuthorizationService(db as never, redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
|
||||||
|
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('not authorized');
|
||||||
|
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
|
||||||
|
const result = await buildExecutor().execute(payload, scope('member-1'));
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('approval');
|
||||||
|
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
|
||||||
|
const executor = buildExecutor(createDurableAuthorization());
|
||||||
|
|
||||||
|
const adminScope = scope('admin-1');
|
||||||
|
const denied = await executor.execute(payload, adminScope);
|
||||||
|
const approval = await executor.createApproval(payload, adminScope);
|
||||||
|
const approved = await executor.execute(
|
||||||
|
{ ...payload, approvalId: approval?.approvalId },
|
||||||
|
adminScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(denied.success).toBe(false);
|
||||||
|
expect(denied.message).toContain('approval');
|
||||||
|
expect(approval).not.toBeNull();
|
||||||
|
// A valid durable approval is consumed, but cannot authorize an unimplemented
|
||||||
|
// global retention operation. Session-scoped cleanup remains lifecycle-only.
|
||||||
|
expect(approved.success).toBe(false);
|
||||||
|
expect(approved.message).toContain('Global GC is disabled');
|
||||||
|
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue';
|
|||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
@@ -10,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js';
|
|||||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -32,10 +34,17 @@ export class CommandExecutorService {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@Inject(McpClientService)
|
@Inject(McpClientService)
|
||||||
private readonly mcpClient: McpClientService | null,
|
private readonly mcpClient: McpClientService | null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(CommandAuthorizationService)
|
||||||
|
private readonly authorization: CommandAuthorizationService | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
async execute(
|
||||||
|
payload: SlashCommandPayload,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
const { command, args, conversationId } = payload;
|
const { command, args, conversationId } = payload;
|
||||||
|
const userId = scope.userId;
|
||||||
|
|
||||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||||
if (!def) {
|
if (!def) {
|
||||||
@@ -47,14 +56,24 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authorization = await this.authorization?.authorize(
|
||||||
|
def,
|
||||||
|
payload,
|
||||||
|
userId,
|
||||||
|
payload.approvalId,
|
||||||
|
);
|
||||||
|
if (authorization && !authorization.allowed) {
|
||||||
|
return { command, conversationId, success: false, message: authorization.reason };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'model':
|
case 'model':
|
||||||
return await this.handleModel(args ?? null, conversationId);
|
return await this.handleModel(args ?? null, conversationId, scope);
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
return await this.handleThinking(args ?? null, conversationId);
|
return await this.handleThinking(args ?? null, conversationId);
|
||||||
case 'system':
|
case 'system':
|
||||||
return await this.handleSystem(args ?? null, conversationId);
|
return await this.handleSystem(args ?? null, conversationId, scope);
|
||||||
case 'new':
|
case 'new':
|
||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
@@ -83,18 +102,17 @@ export class CommandExecutorService {
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Retry last message requested.',
|
message: 'Retry last message requested.',
|
||||||
};
|
};
|
||||||
case 'gc': {
|
case 'gc':
|
||||||
// Admin-only: system-wide GC sweep across all sessions
|
// Global retention requires a separate, authorized and audited job.
|
||||||
const result = await this.sessionGC.sweepOrphans();
|
// Session cleanup is performed only through the session lifecycle.
|
||||||
return {
|
return {
|
||||||
command: 'gc',
|
command: 'gc',
|
||||||
success: true,
|
success: false,
|
||||||
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
message: 'Global GC is disabled pending an authorized retention job.',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
case 'agent':
|
case 'agent':
|
||||||
return await this.handleAgent(args ?? null, conversationId, userId);
|
return await this.handleAgent(args ?? null, conversationId, scope);
|
||||||
case 'provider':
|
case 'provider':
|
||||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||||
case 'mission':
|
case 'mission':
|
||||||
@@ -143,13 +161,22 @@ export class CommandExecutorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
|
||||||
|
const def = this.registry
|
||||||
|
.getManifest()
|
||||||
|
.commands.find((command) => command.name === payload.command);
|
||||||
|
if (!def || !this.authorization) return null;
|
||||||
|
return this.authorization.createApproval(def, payload, scope.userId);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleModel(
|
private async handleModel(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!args || args.trim().length === 0) {
|
if (!args || args.trim().length === 0) {
|
||||||
// Show current override or usage hint
|
// Show current override or usage hint
|
||||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
|
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
|
||||||
if (currentOverride) {
|
if (currentOverride) {
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
@@ -171,7 +198,7 @@ export class CommandExecutorService {
|
|||||||
|
|
||||||
// /model clear removes the override and re-enables automatic routing
|
// /model clear removes the override and re-enables automatic routing
|
||||||
if (modelName === 'clear') {
|
if (modelName === 'clear') {
|
||||||
this.chatGateway?.setModelOverride(conversationId, null);
|
this.chatGateway?.setModelOverride(conversationId, null, scope);
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -181,9 +208,9 @@ export class CommandExecutorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set the sticky per-session override (M4-007)
|
// Set the sticky per-session override (M4-007)
|
||||||
this.chatGateway?.setModelOverride(conversationId, modelName);
|
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
|
||||||
|
|
||||||
const session = this.agentService.getSession(conversationId);
|
const session = this.agentService.getSession(conversationId, scope);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
@@ -224,10 +251,11 @@ export class CommandExecutorService {
|
|||||||
private async handleSystem(
|
private async handleSystem(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!args || args.trim().length === 0) {
|
if (!args || args.trim().length === 0) {
|
||||||
// Clear the override when called with no args
|
// Clear the override when called with no args
|
||||||
await this.systemOverride.clear(conversationId);
|
await this.systemOverride.clear(conversationId, scope);
|
||||||
return {
|
return {
|
||||||
command: 'system',
|
command: 'system',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -236,7 +264,7 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.systemOverride.set(conversationId, args.trim());
|
await this.systemOverride.set(conversationId, args.trim(), scope);
|
||||||
return {
|
return {
|
||||||
command: 'system',
|
command: 'system',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -248,8 +276,9 @@ export class CommandExecutorService {
|
|||||||
private async handleAgent(
|
private async handleAgent(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
userId: string,
|
scope: ActorTenantScope,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
const userId = scope.userId;
|
||||||
if (!args) {
|
if (!args) {
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
@@ -338,11 +367,14 @@ export class CommandExecutorService {
|
|||||||
conversationId,
|
conversationId,
|
||||||
agentConfig.id,
|
agentConfig.id,
|
||||||
agentConfig.name,
|
agentConfig.name,
|
||||||
|
scope,
|
||||||
agentConfig.model ?? undefined,
|
agentConfig.model ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
||||||
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
|
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
|
||||||
|
agentName: agentConfig.name,
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
||||||
@@ -403,22 +435,28 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pollToken = crypto.randomUUID();
|
const pollToken = crypto.randomUUID();
|
||||||
const key = `mosaic:auth:poll:${pollToken}`;
|
const tokenDigest = await crypto.subtle.digest(
|
||||||
// Store pending state in Valkey (TTL 5 minutes)
|
'SHA-256',
|
||||||
|
new TextEncoder().encode(pollToken),
|
||||||
|
);
|
||||||
|
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
|
||||||
|
byte.toString(16).padStart(2, '0'),
|
||||||
|
).join('');
|
||||||
|
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||||
|
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||||
|
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
key,
|
key,
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
'EX',
|
'EX',
|
||||||
300,
|
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 {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
|
||||||
conversationId,
|
conversationId,
|
||||||
data: { loginUrl, pollToken, provider: providerName },
|
data: { provider: providerName },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
let registry: CommandRegistryService;
|
let registry: CommandRegistryService;
|
||||||
let executor: CommandExecutorService;
|
let executor: CommandExecutorService;
|
||||||
const userId = 'user-integ-001';
|
const userId = 'user-integ-001';
|
||||||
|
const userScope = { userId, tenantId: userId };
|
||||||
const conversationId = 'conv-integ-001';
|
const conversationId = 'conv-integ-001';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -170,28 +171,26 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// Unknown command returns error
|
// Unknown command returns error
|
||||||
it('unknown command returns success:false with descriptive message', async () => {
|
it('unknown command returns success:false with descriptive message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('nonexistent');
|
expect(result.message).toContain('nonexistent');
|
||||||
expect(result.command).toBe('nonexistent');
|
expect(result.command).toBe('nonexistent');
|
||||||
});
|
});
|
||||||
|
|
||||||
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
it('/gc refuses an unaudited global sweep', async () => {
|
||||||
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
|
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('GC sweep complete');
|
expect(result.message).toContain('disabled pending an authorized retention job');
|
||||||
expect(result.message).toContain('3 orphaned sessions');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// /system with args calls SystemOverrideService.set
|
// /system with args calls SystemOverrideService.set
|
||||||
it('/system with text calls SystemOverrideService.set', async () => {
|
it('/system with text calls SystemOverrideService.set', async () => {
|
||||||
const override = 'You are a helpful assistant.';
|
const override = 'You are a helpful assistant.';
|
||||||
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('override set');
|
expect(result.message).toContain('override set');
|
||||||
});
|
});
|
||||||
@@ -199,8 +198,8 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /system with no args clears the override
|
// /system with no args clears the override
|
||||||
it('/system with no args calls SystemOverrideService.clear', async () => {
|
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('cleared');
|
expect(result.message).toContain('cleared');
|
||||||
});
|
});
|
||||||
@@ -212,7 +211,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
args: 'claude-3-opus',
|
args: 'claude-3-opus',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('model');
|
expect(result.command).toBe('model');
|
||||||
expect(result.message).toContain('claude-3-opus');
|
expect(result.message).toContain('claude-3-opus');
|
||||||
@@ -221,7 +220,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /thinking with valid level returns success
|
// /thinking with valid level returns success
|
||||||
it('/thinking with valid level returns success', async () => {
|
it('/thinking with valid level returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('high');
|
expect(result.message).toContain('high');
|
||||||
});
|
});
|
||||||
@@ -229,7 +228,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /thinking with invalid level returns usage message
|
// /thinking with invalid level returns usage message
|
||||||
it('/thinking with invalid level returns usage message', async () => {
|
it('/thinking with invalid level returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage:');
|
expect(result.message).toContain('Usage:');
|
||||||
});
|
});
|
||||||
@@ -237,7 +236,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /new command returns success
|
// /new command returns success
|
||||||
it('/new returns success', async () => {
|
it('/new returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('new');
|
expect(result.command).toBe('new');
|
||||||
});
|
});
|
||||||
@@ -245,7 +244,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /reload without reloadService returns failure
|
// /reload without reloadService returns failure
|
||||||
it('/reload without ReloadService returns failure', async () => {
|
it('/reload without ReloadService returns failure', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('ReloadService');
|
expect(result.message).toContain('ReloadService');
|
||||||
});
|
});
|
||||||
@@ -255,7 +254,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
for (const cmd of stubCommands) {
|
for (const cmd of stubCommands) {
|
||||||
it(`/${cmd} returns success (stub)`, async () => {
|
it(`/${cmd} returns success (stub)`, async () => {
|
||||||
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe(cmd);
|
expect(result.command).toBe(cmd);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
|||||||
import { ChatModule } from '../chat/chat.module.js';
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
|
||||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||||
@@ -24,9 +26,16 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
|
CommandAuthorizationService,
|
||||||
|
CommandRuntimeApprovalVerifier,
|
||||||
|
CommandExecutorService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
CommandRegistryService,
|
||||||
|
CommandAuthorizationService,
|
||||||
|
CommandRuntimeApprovalVerifier,
|
||||||
CommandExecutorService,
|
CommandExecutorService,
|
||||||
],
|
],
|
||||||
exports: [CommandRegistryService, CommandExecutorService],
|
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||||
|
|||||||
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
RuntimeApprovalVerifier,
|
||||||
|
RuntimeTerminationAction,
|
||||||
|
} from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter from the provider registry's exact termination action to the shared,
|
||||||
|
* Redis-backed `interaction:command-approval:*` store. It has no separate approval
|
||||||
|
* persistence or replay semantics.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
constructor(
|
||||||
|
@Inject(CommandAuthorizationService)
|
||||||
|
private readonly authorization: CommandAuthorizationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean> {
|
||||||
|
return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,32 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
|
||||||
import { CoordService } from './coord.service.js';
|
import { CoordService } from './coord.service.js';
|
||||||
import { CoordController } from './coord.controller.js';
|
import { CoordController } from './coord.controller.js';
|
||||||
|
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||||
|
import {
|
||||||
|
COORDINATION_CONFIG,
|
||||||
|
COORDINATION_PORT,
|
||||||
|
InteractionCoordinationService,
|
||||||
|
} from './interaction-coordination.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [CoordService],
|
providers: [
|
||||||
controllers: [CoordController],
|
CoordService,
|
||||||
exports: [CoordService],
|
{
|
||||||
|
provide: COORDINATION_PORT,
|
||||||
|
useFactory: (): InMemoryInteractionCoordinationPort =>
|
||||||
|
new InMemoryInteractionCoordinationPort(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: COORDINATION_CONFIG,
|
||||||
|
useFactory: () => ({
|
||||||
|
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
||||||
|
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
InteractionCoordinationService,
|
||||||
|
],
|
||||||
|
controllers: [CoordController, InteractionCoordinationController],
|
||||||
|
exports: [CoordService, InteractionCoordinationService],
|
||||||
})
|
})
|
||||||
export class CoordModule {}
|
export class CoordModule {}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const PATH_METADATA = 'path';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||||
|
|
||||||
|
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
||||||
|
|
||||||
|
describe('InteractionCoordinationController', () => {
|
||||||
|
it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => {
|
||||||
|
expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([
|
||||||
|
'api/coord/interaction',
|
||||||
|
'api/coord/mos',
|
||||||
|
]);
|
||||||
|
expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function');
|
||||||
|
expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function');
|
||||||
|
expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
||||||
|
const coordination = {
|
||||||
|
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||||
|
observe: vi.fn(),
|
||||||
|
result: vi.fn(),
|
||||||
|
};
|
||||||
|
const controller = new InteractionCoordinationController(coordination as never);
|
||||||
|
|
||||||
|
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
|
||||||
|
|
||||||
|
expect(coordination.handoff).toHaveBeenCalledWith(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement' },
|
||||||
|
expect.objectContaining({
|
||||||
|
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a correlation header before invoking the coordination service', async () => {
|
||||||
|
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
||||||
|
const controller = new InteractionCoordinationController(coordination as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
||||||
|
).rejects.toThrow('X-Correlation-Id is required');
|
||||||
|
expect(coordination.handoff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import type {
|
||||||
|
InteractionCoordinationObservationDto,
|
||||||
|
InteractionCoordinationResponseDto,
|
||||||
|
InteractionCoordinationResultDto,
|
||||||
|
CreateHandoffDto,
|
||||||
|
} from './interaction-coordination.dto.js';
|
||||||
|
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||||
|
|
||||||
|
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
|
||||||
|
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
|
||||||
|
@Controller(['api/coord/interaction', 'api/coord/mos'])
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class InteractionCoordinationController {
|
||||||
|
constructor(
|
||||||
|
@Inject(InteractionCoordinationService)
|
||||||
|
private readonly coordination: InteractionCoordinationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('handoff')
|
||||||
|
async handoff(
|
||||||
|
@Body() request: CreateHandoffDto,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
): Promise<InteractionCoordinationResponseDto> {
|
||||||
|
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':handoffId/observe')
|
||||||
|
async observe(
|
||||||
|
@Param('handoffId') handoffId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
): Promise<InteractionCoordinationObservationDto> {
|
||||||
|
return {
|
||||||
|
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':handoffId/result')
|
||||||
|
async result(
|
||||||
|
@Param('handoffId') handoffId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
|
): Promise<InteractionCoordinationResultDto> {
|
||||||
|
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private context(
|
||||||
|
user: AuthenticatedUserLike,
|
||||||
|
correlationId?: string,
|
||||||
|
): RuntimeProviderRequestContext {
|
||||||
|
const requestCorrelationId = correlationId?.trim();
|
||||||
|
if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required');
|
||||||
|
return {
|
||||||
|
actorScope: scopeFromUser(user),
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: requestCorrelationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/gateway/src/coord/interaction-coordination.dto.ts
Normal file
25
apps/gateway/src/coord/interaction-coordination.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type {
|
||||||
|
CoordinationObservation,
|
||||||
|
CoordinationResult,
|
||||||
|
HandoffReceipt,
|
||||||
|
} from '@mosaicstack/coord';
|
||||||
|
|
||||||
|
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
||||||
|
export interface CreateHandoffDto {
|
||||||
|
idempotencyKey: string;
|
||||||
|
summary: string;
|
||||||
|
context?: string;
|
||||||
|
missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractionCoordinationResponseDto {
|
||||||
|
receipt: HandoffReceipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractionCoordinationObservationDto {
|
||||||
|
observation: CoordinationObservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractionCoordinationResultDto {
|
||||||
|
result: CoordinationResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||||
|
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AUTH,
|
||||||
|
useValue: {
|
||||||
|
api: {
|
||||||
|
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
|
||||||
|
headers.get('cookie') === 'session=trusted'
|
||||||
|
? { user: { id: 'operator-1', tenantId: 'tenant-1' }, session: { id: 'session-1' } }
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AuthGuard,
|
||||||
|
],
|
||||||
|
exports: [AUTH, AuthGuard],
|
||||||
|
})
|
||||||
|
class AuthenticatedRequestModule {}
|
||||||
|
|
||||||
|
describe('InteractionCoordinationController route aliases', (): void => {
|
||||||
|
let app: NestFastifyApplication | undefined;
|
||||||
|
const coordination = {
|
||||||
|
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||||
|
observe: vi.fn(async () => ({ status: 'running' })),
|
||||||
|
result: vi.fn(async () => ({ status: 'completed' })),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async (): Promise<void> => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AuthenticatedRequestModule],
|
||||||
|
controllers: [InteractionCoordinationController],
|
||||||
|
providers: [{ provide: InteractionCoordinationService, useValue: coordination }],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
afterAll(async (): Promise<void> => app?.close());
|
||||||
|
|
||||||
|
it('routes handoff, observe, and result through the same AuthGuard-protected service for both prefixes', async (): Promise<void> => {
|
||||||
|
if (!app) throw new Error('test app was not initialized');
|
||||||
|
for (const prefix of ['/api/coord/interaction', '/api/coord/mos']) {
|
||||||
|
const headers = { cookie: 'session=trusted', 'x-correlation-id': `corr-${prefix}` };
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${prefix}/handoff`,
|
||||||
|
headers,
|
||||||
|
payload: { idempotencyKey: `key-${prefix}`, summary: 'handoff' },
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(201);
|
||||||
|
expect(
|
||||||
|
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/observe`, headers }))
|
||||||
|
.statusCode,
|
||||||
|
).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/result`, headers }))
|
||||||
|
.statusCode,
|
||||||
|
).toBe(200);
|
||||||
|
}
|
||||||
|
expect(coordination.handoff).toHaveBeenCalledTimes(2);
|
||||||
|
expect(coordination.observe).toHaveBeenCalledTimes(2);
|
||||||
|
expect(coordination.result).toHaveBeenCalledTimes(2);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/coord/interaction/handoff',
|
||||||
|
payload: { idempotencyKey: 'denied', summary: 'x' },
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
217
apps/gateway/src/coord/interaction-coordination.service.test.ts
Normal file
217
apps/gateway/src/coord/interaction-coordination.service.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
InMemoryInteractionCoordinationPort,
|
||||||
|
type InteractionCoordinationPort,
|
||||||
|
type Handoff,
|
||||||
|
} from '@mosaicstack/coord';
|
||||||
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import {
|
||||||
|
InteractionCoordinationService,
|
||||||
|
type InteractionCoordinationConfig,
|
||||||
|
type InteractionCoordinationGatewayError,
|
||||||
|
} from './interaction-coordination.service.js';
|
||||||
|
|
||||||
|
const context: RuntimeProviderRequestContext = {
|
||||||
|
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: InteractionCoordinationConfig = {
|
||||||
|
interactionAgentId: 'Nova',
|
||||||
|
orchestrationAgentId: 'Conductor',
|
||||||
|
};
|
||||||
|
|
||||||
|
function service(
|
||||||
|
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
|
||||||
|
options: {
|
||||||
|
config?: InteractionCoordinationConfig;
|
||||||
|
handoffIdFactory?: () => string;
|
||||||
|
} = {},
|
||||||
|
): InteractionCoordinationService {
|
||||||
|
return new InteractionCoordinationService(
|
||||||
|
port,
|
||||||
|
options.config ?? config,
|
||||||
|
options.handoffIdFactory ?? (() => 'handoff-1'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InteractionCoordinationService authority boundary', (): void => {
|
||||||
|
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
|
const coordination = service(adapter);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).resolves.toEqual({
|
||||||
|
handoffId: 'handoff-1',
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'queued',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
||||||
|
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
||||||
|
|
||||||
|
const followUpContext = { ...context, correlationId: 'corr-2' };
|
||||||
|
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
|
targetAgentId: 'Conductor',
|
||||||
|
status: 'completed',
|
||||||
|
summary: 'Merged by orchestrator',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
|
const coordination = service(adapter, {
|
||||||
|
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'unconfigured_requester',
|
||||||
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
|
expect(handoff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
|
const coordination = service(adapter, {
|
||||||
|
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Interaction and orchestration identities must differ');
|
||||||
|
expect(handoff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
|
const observe = vi.spyOn(adapter, 'observe');
|
||||||
|
const result = vi.spyOn(adapter, 'result');
|
||||||
|
const coordination = service(adapter);
|
||||||
|
await coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherTenant = {
|
||||||
|
...context,
|
||||||
|
actorScope: { ...context.actorScope, tenantId: 'tenant-b' },
|
||||||
|
};
|
||||||
|
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
|
code: 'cross_tenant_forbidden',
|
||||||
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
|
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
|
code: 'cross_tenant_forbidden',
|
||||||
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
|
expect(observe).not.toHaveBeenCalled();
|
||||||
|
expect(result).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise<void> => {
|
||||||
|
let handoffSequence = 0;
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
const delivered = new Promise<void>((resolve: () => void): void => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const adapter: InteractionCoordinationPort = {
|
||||||
|
handoff: vi.fn(async (handoff: Handoff) => {
|
||||||
|
await delivered;
|
||||||
|
return {
|
||||||
|
handoffId: handoff.handoffId,
|
||||||
|
targetAgentId: handoff.targetAgentId,
|
||||||
|
status: 'queued' as const,
|
||||||
|
correlationId: handoff.scope.correlationId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
observe: vi.fn(),
|
||||||
|
result: vi.fn(),
|
||||||
|
};
|
||||||
|
const coordination = service(adapter, {
|
||||||
|
handoffIdFactory: (): string => `handoff-${++handoffSequence}`,
|
||||||
|
});
|
||||||
|
const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' };
|
||||||
|
|
||||||
|
const first = coordination.handoff(request, context);
|
||||||
|
const retry = coordination.handoff(request, context);
|
||||||
|
expect(adapter.handoff).toHaveBeenCalledTimes(1);
|
||||||
|
release?.();
|
||||||
|
await expect(Promise.all([first, retry])).resolves.toEqual([
|
||||||
|
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||||
|
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff(request, {
|
||||||
|
...context,
|
||||||
|
actorScope: { ...context.actorScope, userId: 'operator-2' },
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ handoffId: 'handoff-2' });
|
||||||
|
expect(adapter.handoff).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
||||||
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
|
const coordination = service(adapter);
|
||||||
|
await coordination.handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'handoff_conflict',
|
||||||
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
|
await expect(
|
||||||
|
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'invalid_request',
|
||||||
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
|
await expect(
|
||||||
|
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'invalid_request',
|
||||||
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
|
expect(handoff).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
||||||
|
const adapter: InteractionCoordinationPort = {
|
||||||
|
handoff: vi.fn(async (handoff: Handoff) => ({
|
||||||
|
handoffId: handoff.handoffId,
|
||||||
|
targetAgentId: 'Unexpected',
|
||||||
|
status: 'accepted' as const,
|
||||||
|
correlationId: handoff.scope.correlationId,
|
||||||
|
})),
|
||||||
|
observe: vi.fn(),
|
||||||
|
result: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service(adapter).handoff(
|
||||||
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: 'target_drift' });
|
||||||
|
});
|
||||||
|
});
|
||||||
303
apps/gateway/src/coord/interaction-coordination.service.ts
Normal file
303
apps/gateway/src/coord/interaction-coordination.service.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
InteractionCoordinationClient,
|
||||||
|
type CoordinationObservation,
|
||||||
|
type CoordinationResult,
|
||||||
|
type CoordinationScope,
|
||||||
|
type InteractionCoordinationIdentity,
|
||||||
|
type InteractionCoordinationPort,
|
||||||
|
type HandoffReceipt,
|
||||||
|
} from '@mosaicstack/coord';
|
||||||
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
||||||
|
|
||||||
|
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||||
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||||
|
|
||||||
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
|
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
||||||
|
const MAX_SUMMARY_LENGTH = 2_048;
|
||||||
|
const MAX_CONTEXT_LENGTH = 8_192;
|
||||||
|
const MAX_MISSION_ID_LENGTH = 128;
|
||||||
|
|
||||||
|
export interface InteractionCoordinationConfig {
|
||||||
|
interactionAgentId?: string;
|
||||||
|
orchestrationAgentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HandoffOwner {
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
requesterAgentId: string;
|
||||||
|
correlationId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalizedHandoffRequest {
|
||||||
|
idempotencyKey: string;
|
||||||
|
summary: string;
|
||||||
|
context?: string;
|
||||||
|
missionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackedHandoff {
|
||||||
|
request: NormalizedHandoffRequest;
|
||||||
|
receipt: Promise<HandoffReceipt>;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway authority boundary for the interaction agent. It derives requester,
|
||||||
|
* actor, and tenant from trusted server configuration and authentication; no
|
||||||
|
* channel request can name a target or gain orchestrator-owned orchestration verbs.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class InteractionCoordinationService {
|
||||||
|
private readonly owners = new Map<string, HandoffOwner>();
|
||||||
|
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||||
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||||
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handoff(
|
||||||
|
request: CreateHandoffDto,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<HandoffReceipt> {
|
||||||
|
this.pruneExpiredTracking();
|
||||||
|
const normalized = this.normalizeRequest(request);
|
||||||
|
const scope = this.scope(context);
|
||||||
|
const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope);
|
||||||
|
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
if (!sameRequest(existing.request, normalized)) {
|
||||||
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'handoff_conflict',
|
||||||
|
'Handoff idempotency key is already bound to different immutable input',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return existing.receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope);
|
||||||
|
const tracked: TrackedHandoff = {
|
||||||
|
request: normalized,
|
||||||
|
receipt: pending,
|
||||||
|
expiresAt: this.expiresAt(),
|
||||||
|
};
|
||||||
|
this.handoffsByIdempotencyKey.set(idempotencyKey, tracked);
|
||||||
|
this.enforceTrackingLimit(this.handoffsByIdempotencyKey);
|
||||||
|
try {
|
||||||
|
return await pending;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) {
|
||||||
|
this.handoffsByIdempotencyKey.delete(idempotencyKey);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(
|
||||||
|
handoffId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<CoordinationObservation> {
|
||||||
|
this.pruneExpiredTracking();
|
||||||
|
const scope = this.scope(context);
|
||||||
|
const owner = this.ownerFor(handoffId, scope);
|
||||||
|
return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async result(
|
||||||
|
handoffId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<CoordinationResult> {
|
||||||
|
this.pruneExpiredTracking();
|
||||||
|
const scope = this.scope(context);
|
||||||
|
const owner = this.ownerFor(handoffId, scope);
|
||||||
|
return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverHandoff(
|
||||||
|
handoffId: string,
|
||||||
|
request: NormalizedHandoffRequest,
|
||||||
|
scope: CoordinationScope,
|
||||||
|
): Promise<HandoffReceipt> {
|
||||||
|
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
||||||
|
const owner: HandoffOwner = {
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
requesterAgentId: scope.requesterAgentId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
expiresAt: this.expiresAt(),
|
||||||
|
};
|
||||||
|
const existing = this.owners.get(receipt.handoffId);
|
||||||
|
if (existing !== undefined && !sameOwner(existing, owner)) {
|
||||||
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'handoff_conflict',
|
||||||
|
'Handoff ID is already bound to a different authenticated scope',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.owners.set(receipt.handoffId, owner);
|
||||||
|
this.enforceTrackingLimit(this.owners);
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
|
||||||
|
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private identity(): InteractionCoordinationIdentity {
|
||||||
|
const interactionAgentId = this.config.interactionAgentId?.trim();
|
||||||
|
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
||||||
|
if (!interactionAgentId) {
|
||||||
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'unconfigured_requester',
|
||||||
|
'Interaction agent identity is not configured',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!orchestrationAgentId) {
|
||||||
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'unconfigured_target',
|
||||||
|
'Orchestration agent identity is not configured',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { interactionAgentId, orchestrationAgentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private scope(context: RuntimeProviderRequestContext): CoordinationScope {
|
||||||
|
const identity = this.identity();
|
||||||
|
return Object.freeze({
|
||||||
|
actorId: context.actorScope.userId,
|
||||||
|
tenantId: context.actorScope.tenantId,
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
requesterAgentId: identity.interactionAgentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
|
||||||
|
if (typeof request !== 'object' || request === null) {
|
||||||
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'invalid_request',
|
||||||
|
'Handoff request is invalid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const idempotencyKey = this.requiredString(
|
||||||
|
request.idempotencyKey,
|
||||||
|
'idempotency key',
|
||||||
|
MAX_IDEMPOTENCY_KEY_LENGTH,
|
||||||
|
);
|
||||||
|
const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH);
|
||||||
|
const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH);
|
||||||
|
const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH);
|
||||||
|
return Object.freeze({
|
||||||
|
idempotencyKey,
|
||||||
|
summary,
|
||||||
|
...(context === undefined ? {} : { context }),
|
||||||
|
...(missionId === undefined ? {} : { missionId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private idempotencyKey(requestKey: string, scope: CoordinationScope): string {
|
||||||
|
return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'invalid_request',
|
||||||
|
`Handoff ${field} must be a string`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized.length === 0 || normalized.length > maximumLength) {
|
||||||
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'invalid_request',
|
||||||
|
`Handoff ${field} is invalid`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private optionalString(value: unknown, field: string, maximumLength: number): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
return this.requiredString(value, field, maximumLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private expiresAt(): number {
|
||||||
|
return Date.now() + HANDOFF_TRACKING_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneExpiredTracking(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, tracked] of this.handoffsByIdempotencyKey) {
|
||||||
|
if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key);
|
||||||
|
}
|
||||||
|
for (const [key, owner] of this.owners) {
|
||||||
|
if (owner.expiresAt <= now) this.owners.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enforceTrackingLimit<T>(entries: Map<string, T>): void {
|
||||||
|
while (entries.size > MAX_TRACKED_HANDOFFS) {
|
||||||
|
const oldest = entries.keys().next().value;
|
||||||
|
if (typeof oldest !== 'string') return;
|
||||||
|
entries.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
||||||
|
const owner = this.owners.get(handoffId);
|
||||||
|
if (owner === undefined) {
|
||||||
|
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
owner.tenantId !== scope.tenantId ||
|
||||||
|
owner.actorId !== scope.actorId ||
|
||||||
|
owner.requesterAgentId !== scope.requesterAgentId
|
||||||
|
) {
|
||||||
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'cross_tenant_forbidden',
|
||||||
|
'Handoff is outside the authenticated scope',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InteractionCoordinationGatewayErrorCode =
|
||||||
|
| 'cross_tenant_forbidden'
|
||||||
|
| 'handoff_conflict'
|
||||||
|
| 'invalid_request'
|
||||||
|
| 'not_found'
|
||||||
|
| 'unconfigured_requester'
|
||||||
|
| 'unconfigured_target';
|
||||||
|
|
||||||
|
function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
||||||
|
return (
|
||||||
|
left.actorId === right.actorId &&
|
||||||
|
left.tenantId === right.tenantId &&
|
||||||
|
left.requesterAgentId === right.requesterAgentId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
|
||||||
|
return (
|
||||||
|
left.idempotencyKey === right.idempotencyKey &&
|
||||||
|
left.summary === right.summary &&
|
||||||
|
left.context === right.context &&
|
||||||
|
left.missionId === right.missionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InteractionCoordinationGatewayError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: InteractionCoordinationGatewayErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = InteractionCoordinationGatewayError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
import {
|
||||||
import { createDb, createPgliteDb, type Db, type DbHandle } from '@mosaicstack/db';
|
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 { createStorageAdapter, type StorageAdapter } from '@mosaicstack/storage';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
@@ -39,12 +52,37 @@ export const STORAGE_ADAPTER = 'STORAGE_ADAPTER';
|
|||||||
],
|
],
|
||||||
exports: [DB, 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(
|
constructor(
|
||||||
@Inject(DB_HANDLE) private readonly handle: DbHandle,
|
@Inject(DB_HANDLE) private readonly handle: DbHandle,
|
||||||
@Inject(STORAGE_ADAPTER) private readonly storageAdapter: StorageAdapter,
|
@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> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await Promise.all([this.handle.close(), this.storageAdapter.close()]);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/gateway/src/federation/federation.module.ts
Normal file
38
apps/gateway/src/federation/federation.module.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 { 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],
|
||||||
|
providers: [
|
||||||
|
AdminGuard,
|
||||||
|
CaService,
|
||||||
|
EnrollmentService,
|
||||||
|
GrantsService,
|
||||||
|
FederationClientService,
|
||||||
|
QuerySourceService,
|
||||||
|
FederationAuthGuard,
|
||||||
|
FederationScopeService,
|
||||||
|
FederationListQueryService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
CaService,
|
||||||
|
EnrollmentService,
|
||||||
|
GrantsService,
|
||||||
|
FederationClientService,
|
||||||
|
QuerySourceService,
|
||||||
|
FederationAuthGuard,
|
||||||
|
FederationScopeService,
|
||||||
|
FederationListQueryService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user