Compare commits
156
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90cf286a09 | ||
|
|
0aef432052 | ||
|
|
e16c08aa9f | ||
|
|
a4861c221f | ||
|
|
46d68e1ff4 | ||
|
|
068d0f9b1c | ||
|
|
24bbd40dc7 | ||
|
|
4df478cdd1 | ||
|
|
b8844e1ff0 | ||
|
|
906ad8dc30 | ||
|
|
5916aeefd6 | ||
|
|
58b971aba3 | ||
|
|
f4fd5967fc | ||
|
|
f65e9ea656 | ||
|
|
f58b3699a6 | ||
|
|
01e966f36d | ||
|
|
524146055d | ||
|
|
06e0d40352 | ||
|
|
166ee8c90f | ||
|
|
826a8b3b26 | ||
|
|
a4280b9c98 | ||
|
|
4fb44f6345 | ||
|
|
089615f63b | ||
|
|
76eef39a29 | ||
|
|
47f8689231 | ||
|
|
8d1d6e5e76 | ||
|
|
6a7fce34bb | ||
|
|
a6b5f6a01a | ||
|
|
539b475a92 | ||
|
|
3e47fc076f | ||
|
|
8710d0f6d7 | ||
|
|
b981b4ec10 | ||
|
|
9e81ffd7fc | ||
|
|
9becaf877f | ||
|
|
17087efe15 | ||
|
|
0ea41e848b | ||
|
|
347c1d57c1 | ||
|
|
13e6ce5e5c | ||
|
|
90265ef550 | ||
|
|
937a276208 | ||
|
|
712c770b7a | ||
|
|
d967a4a926 | ||
|
|
c585ac3326 | ||
|
|
e2ec927b1c | ||
|
|
2378665eaf | ||
|
|
003cdaa1a6 | ||
|
|
320f5bfb6f | ||
|
|
5df47e735e | ||
|
|
dd1391fd76 | ||
|
|
10d957d095 | ||
|
|
dc45eb7c30 | ||
|
|
28f022d9c0 | ||
|
|
2726fab5e0 | ||
|
|
ab6e8e80dc | ||
|
|
1933c6cb1d | ||
|
|
48a0c86093 | ||
|
|
79c8647fd9 | ||
|
|
2483dada33 | ||
|
|
4c117afe03 | ||
|
|
2698ddb7b5 | ||
|
|
fabde1c834 | ||
|
|
3c7890f17f | ||
|
|
529c177830 | ||
|
|
a32ce4c8f9 | ||
|
|
d351caad36 | ||
|
|
76b86a246e | ||
|
|
4422231bdb | ||
|
|
8504216964 | ||
|
|
7edc9b3121 | ||
|
|
2f50c0876b | ||
|
|
db90da347e | ||
|
|
48fd1df28a | ||
|
|
b79336a8c1 | ||
|
|
4e5af23214 | ||
|
|
880c28b191 | ||
|
|
7bc2dfb6c8 | ||
|
|
b0d78d8632 | ||
|
|
344d86a635 | ||
|
|
acd7d380f6 | ||
|
|
3b70c66c07 | ||
|
|
11d2818453 | ||
|
|
aa999daf1b | ||
|
|
77c9a82614 | ||
|
|
627cf2bb38 | ||
|
|
0582a8912b | ||
|
|
2509eb7646 | ||
|
|
07553ead33 | ||
|
|
e522b22fa4 | ||
|
|
e4d7d4502d | ||
|
|
8dfcf1903e | ||
|
|
abd2791f59 | ||
|
|
8ec67a1126 | ||
|
|
d801d6c4c8 | ||
|
|
d3bf52898b | ||
|
|
3f77229e88 | ||
|
|
686c881fe4 | ||
|
|
fe7a468c9d | ||
|
|
cabf02e7b9 | ||
|
|
9ddc6fbda8 | ||
|
|
31607a4af6 | ||
|
|
32a0ffba13 | ||
|
|
8536454257 | ||
|
|
4f29cc604d | ||
|
|
3be443c96d | ||
|
|
59f5f51ffd | ||
|
|
9745bc3f29 | ||
|
|
adad486b6f | ||
|
|
c1aecfabe9 | ||
|
|
499090508e | ||
|
|
c593a15ef8 | ||
|
|
bc5e73629e | ||
|
|
191efaefeb | ||
|
|
e9c4aa3e8b | ||
|
|
a5e8e55401 | ||
|
|
eb4e14ae5c | ||
|
|
2e2280070a | ||
|
|
aa5b43bba2 | ||
|
|
ba13c08890 | ||
|
|
c32d85a337 | ||
|
|
48b2bc42c9 | ||
|
|
5e832049bb | ||
|
|
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 | ||
|
|
193331544d | ||
|
|
495f73bfdb | ||
|
|
b96cc7982a | ||
|
|
0883fb91ec | ||
|
|
56787fabf1 | ||
|
|
940ae3cc41 | ||
|
|
c25a551c28 | ||
|
|
94d6538061 | ||
|
|
a3c1ab923c | ||
|
|
838701bde2 |
@@ -8,6 +8,7 @@ coverage
|
|||||||
.env.local
|
.env.local
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
__pycache__/
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
pnpm typecheck && pnpm lint && pnpm format:check
|
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
|
|||||||
@@ -1,5 +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),
|
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||||
# instead of repopulating a fresh one.
|
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||||
store-dir=/root/.local/share/pnpm/store
|
store-dir=${HOME}/.local/share/pnpm/store
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ pnpm-lock.yaml
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
|
# Python build/test artifacts — same category as node_modules/dist/.next above.
|
||||||
|
# Prettier must never scan generated trees; without these a local venv poisons
|
||||||
|
# `pnpm format:check` with thousands of third-party files.
|
||||||
|
**/venv
|
||||||
|
**/__pycache__
|
||||||
|
**/.mypy_cache
|
||||||
|
**/.pytest_cache
|
||||||
|
**/htmlcov
|
||||||
.claude/
|
.claude/
|
||||||
docs/tess/TASKS.md
|
docs/tess/TASKS.md
|
||||||
docs/scratchpads/
|
docs/scratchpads/
|
||||||
|
packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json
|
||||||
|
|||||||
@@ -41,6 +41,32 @@ steps:
|
|||||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
# (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 --self-test
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||||
|
# Test-membership guard (#1017): also first link of test:framework-shell.
|
||||||
|
# Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
|
||||||
|
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||||
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
|
|
||||||
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
|
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
|
||||||
|
# keep mode is a single cp-based path that must not depend on rsync), and that a
|
||||||
|
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
|
||||||
|
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
|
||||||
|
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
|
||||||
|
# the retained, operator-scoped pre-update backup is taken before any mutation
|
||||||
|
# (0700/0600, secret never logged, retention-pruned) and that the post-sync
|
||||||
|
# verify net restores any operator file a manifest bug lets the sync touch. The
|
||||||
|
# migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
|
||||||
|
# node_modules — runs early alongside sanitization.
|
||||||
|
upgrade-guard:
|
||||||
|
image: *node_image
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache bash rsync
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -50,6 +76,7 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
- sanitization
|
- sanitization
|
||||||
|
- upgrade-guard
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
lint:
|
lint:
|
||||||
@@ -76,6 +103,12 @@ steps:
|
|||||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
|
# openssl (#912) is the wake HMAC signer: the digest H1/H2, beacon B12,
|
||||||
|
# and install I8 legs hard-require it in CI. It is baked into ci-base via
|
||||||
|
# Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this
|
||||||
|
# `apk add` guarantees openssl is present on PR pipelines too (and is a
|
||||||
|
# fast no-op once the rebuilt image already ships it).
|
||||||
|
- apk add --no-cache openssl
|
||||||
# postgresql-client (pg_isready) is baked into ci-base.
|
# postgresql-client (pg_isready) is baked into ci-base.
|
||||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||||
- |
|
- |
|
||||||
|
|||||||
+104
-5
@@ -1,5 +1,5 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs only on main branch push/tag
|
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||||
@@ -23,9 +23,21 @@ variables:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- '.woodpecker/**'
|
- '.woodpecker/**'
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: next
|
||||||
|
- &main_image_build_when
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude:
|
||||||
|
- 'packages/mosaic/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- '.woodpecker/**'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, next]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -103,6 +115,84 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
|
publish-next-npm:
|
||||||
|
image: *node_image
|
||||||
|
# Durable @next integration-line publish. Runs only on next; never writes
|
||||||
|
# the latest dist-tag and never commits the computed prerelease versions.
|
||||||
|
when:
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: next
|
||||||
|
environment:
|
||||||
|
NPM_TOKEN:
|
||||||
|
from_secret: gitea_token
|
||||||
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
|
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
||||||
|
commands:
|
||||||
|
- *enable_pnpm
|
||||||
|
- |
|
||||||
|
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
||||||
|
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
||||||
|
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||||
|
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||||
|
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||||
|
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||||
|
const roots = ['apps', 'packages', 'plugins'];
|
||||||
|
const updated = [];
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const packagePath = path.join(fullPath, 'package.json');
|
||||||
|
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||||
|
walk(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePackage(packagePath) {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||||
|
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||||
|
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||||
|
if (!stableMatch) {
|
||||||
|
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||||
|
}
|
||||||
|
const [, major, minor, patch] = stableMatch;
|
||||||
|
const oldVersion = manifest.version;
|
||||||
|
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||||
|
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const root of roots) walk(root);
|
||||||
|
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||||
|
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||||
|
for (const line of updated) console.log('[publish-next] ' + line);
|
||||||
|
NODE
|
||||||
|
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||||
|
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||||
|
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
||||||
|
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||||
|
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||||
|
depends_on:
|
||||||
|
- build
|
||||||
|
|
||||||
# TODO: Uncomment when ready to publish to npmjs.org
|
# TODO: Uncomment when ready to publish to npmjs.org
|
||||||
# publish-npmjs:
|
# publish-npmjs:
|
||||||
# image: *node_image
|
# image: *node_image
|
||||||
@@ -134,8 +224,17 @@ steps:
|
|||||||
- 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/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" = "next" ]; then
|
||||||
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
|
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[publish] next gateway publish is sha-only"
|
||||||
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||||
|
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||||
|
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||||
@@ -146,7 +245,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -172,7 +271,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ pnpm test # Vitest (all packages)
|
|||||||
pnpm build # Build all packages
|
pnpm build # Build all packages
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
|
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
||||||
pnpm --filter @mosaicstack/db db:generate # Generate migrations
|
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
||||||
pnpm --filter @mosaicstack/db db:migrate # Run migrations
|
# init SQL, or Compose PostgreSQL service from this checkout.
|
||||||
|
|
||||||
# Dev
|
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
||||||
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
|
docker compose up -d valkey
|
||||||
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway
|
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
||||||
|
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|||||||
+7
-4
@@ -22,10 +22,13 @@
|
|||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
# Native toolchain required to compile node-gyp deps on musl, plus the
|
# 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`
|
# 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
|
# `git`, and `jq` are baked here too — framework shell tests and the shipped
|
||||||
# `apk add bash`.
|
# Codex review wrappers require them without per-run installation in ci.yml.
|
||||||
RUN apk add --no-cache python3 make g++ postgresql-client bash
|
# `openssl` (#912) is the non-circular HMAC signer for the wake trust layer:
|
||||||
|
# the digest H1/H2, beacon B12, and install I8 legs hard-require it in CI so the
|
||||||
|
# §4 G6 evidence comes from an actually-run HMAC leg, not a skipped one.
|
||||||
|
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq openssl
|
||||||
|
|
||||||
# Pin pnpm to the repo's packageManager version via corepack.
|
# Pin pnpm to the repo's packageManager version via corepack.
|
||||||
RUN corepack enable && corepack prepare [email protected] --activate
|
RUN corepack enable && corepack prepare [email protected] --activate
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ This installs both components:
|
|||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||||
|
|
||||||
|
### Install lanes
|
||||||
|
|
||||||
|
| Lane | Command | Use when | Source |
|
||||||
|
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||||
|
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
||||||
|
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||||
|
|
||||||
|
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -97,7 +107,10 @@ mosaic config path # Print config file path
|
|||||||
```bash
|
```bash
|
||||||
mosaic doctor # Health audit — detect drift and missing files
|
mosaic doctor # Health audit — detect drift and missing files
|
||||||
mosaic sync # Sync skills from canonical source
|
mosaic sync # Sync skills from canonical source
|
||||||
mosaic update # Check for and install CLI updates
|
mosaic skill list # Audit Claude skill registrations and conflicts
|
||||||
|
mosaic skill register <name> # Register one canonical skill with Claude Code
|
||||||
|
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||||
|
mosaic update # Update CLI/framework and auto-register canonical skills
|
||||||
mosaic wizard # Full guided setup wizard
|
mosaic wizard # Full guided setup wizard
|
||||||
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
||||||
mosaic coord init # Initialize a new orchestration mission
|
mosaic coord init # Initialize a new orchestration mission
|
||||||
@@ -157,7 +170,12 @@ mosaic storage status
|
|||||||
mosaic storage tier
|
mosaic storage tier
|
||||||
mosaic storage export
|
mosaic storage export
|
||||||
mosaic storage import
|
mosaic storage import
|
||||||
mosaic storage migrate
|
# Schema migration is unavailable in this release. The current storage wrapper shells
|
||||||
|
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
|
||||||
|
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
||||||
|
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
|
||||||
|
# --run → runner --verify → readiness. Tier copy uses only the separately held secure
|
||||||
|
# migrate-tier route.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Telemetry
|
### Telemetry
|
||||||
@@ -192,33 +210,50 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
|||||||
git clone [email protected]:mosaicstack/stack.git
|
git clone [email protected]:mosaicstack/stack.git
|
||||||
cd stack
|
cd stack
|
||||||
|
|
||||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||||
docker compose up -d
|
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
||||||
|
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
||||||
# Install dependencies
|
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Run migrations
|
# Verify dependencies and generated state before running source-quality gates.
|
||||||
pnpm --filter @mosaicstack/db run db:migrate
|
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||||
|
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||||
|
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||||
|
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
||||||
|
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
||||||
|
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
||||||
|
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
||||||
|
# trust anchor outside worktree authority.
|
||||||
|
pnpm preflight
|
||||||
|
|
||||||
# Start all services in dev mode
|
# Optional local queue service only. This does not start PostgreSQL.
|
||||||
pnpm dev
|
docker compose up -d valkey
|
||||||
|
|
||||||
|
# The current Gateway/Web local process is held; see docs/guides/dev-guide.md.
|
||||||
|
# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Infrastructure
|
### Held future procedure
|
||||||
|
|
||||||
Docker Compose provides:
|
The checked-in Compose PostgreSQL service mounts legacy initialization SQL and is **not** a
|
||||||
|
current PostgreSQL, standalone, or federated developer route. Do not start it with Compose,
|
||||||
|
invoke initialization SQL, or treat the planned migrator as currently executable.
|
||||||
|
|
||||||
| Service | Port | Purpose |
|
**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05
|
||||||
| --------------------- | --------- | ---------------------- |
|
land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run` →
|
||||||
| PostgreSQL (pgvector) | 5433 | Primary database |
|
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
|
||||||
| Valkey | 6380 | Task queue + caching |
|
this README—will provide the reviewed commands and secret-consumer interface.
|
||||||
| Jaeger | 16686 | Distributed tracing UI |
|
|
||||||
| OTEL Collector | 4317/4318 | Telemetry ingestion |
|
For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above
|
||||||
|
starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed,
|
||||||
|
without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route:
|
||||||
|
its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a
|
||||||
|
Gateway start command until KBN-101-02 makes that state fail closed.
|
||||||
|
|
||||||
### Quality Gates
|
### Quality Gates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pnpm preflight # Checkout/dependency/generated-state validation
|
||||||
pnpm typecheck # TypeScript type checking (all packages)
|
pnpm typecheck # TypeScript type checking (all packages)
|
||||||
pnpm lint # ESLint (all packages)
|
pnpm lint # ESLint (all packages)
|
||||||
pnpm test # Vitest (all packages)
|
pnpm test # Vitest (all packages)
|
||||||
@@ -231,7 +266,7 @@ pnpm format # Prettier auto-fix
|
|||||||
Woodpecker CI runs on every push:
|
Woodpecker CI runs on every push:
|
||||||
|
|
||||||
- `pnpm install --frozen-lockfile`
|
- `pnpm install --frozen-lockfile`
|
||||||
- Database migration against a fresh Postgres
|
- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path.
|
||||||
- `pnpm test` (Turbo-orchestrated across all packages)
|
- `pnpm test` (Turbo-orchestrated across all packages)
|
||||||
|
|
||||||
npm packages are published to the Gitea package registry on main merges.
|
npm packages are published to the Gitea package registry on main merges.
|
||||||
@@ -336,11 +371,15 @@ The CLI also performs a background update check on every invocation (cached for
|
|||||||
bash tools/install.sh --check # Version check only
|
bash tools/install.sh --check # Version check only
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --next # Prerelease lane: source build from next
|
||||||
|
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||||
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
/**
|
||||||
|
* Federation M3 single-gateway integration tests (FED-M3-10).
|
||||||
|
*
|
||||||
|
* Covers MILESTONES.md M3 acceptance:
|
||||||
|
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
|
||||||
|
* - #7: max_rows_per_query caps list results.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - Real PostgreSQL via @mosaicstack/db.
|
||||||
|
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
|
||||||
|
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
|
||||||
|
*
|
||||||
|
* Run:
|
||||||
|
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
|
||||||
|
* src/__tests__/integration/federation-m3-list.integration.test.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import type { ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Test, type TestingModule } from '@nestjs/testing';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
createDb,
|
||||||
|
eq,
|
||||||
|
federationGrants,
|
||||||
|
federationPeers,
|
||||||
|
inArray,
|
||||||
|
missionTasks,
|
||||||
|
missions,
|
||||||
|
projects,
|
||||||
|
tasks,
|
||||||
|
teamMembers,
|
||||||
|
teams,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
users,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { DB } from '../../database/database.module.js';
|
||||||
|
import { GrantsService } from '../../federation/grants.service.js';
|
||||||
|
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
|
||||||
|
import { FederationScopeService } from '../../federation/server/scope.service.js';
|
||||||
|
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
|
||||||
|
import { ListController } from '../../federation/server/verbs/list.controller.js';
|
||||||
|
import {
|
||||||
|
makeMosaicIssuedCert,
|
||||||
|
makeSelfSignedCert,
|
||||||
|
} from '../../federation/__tests__/helpers/test-cert.js';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
|
||||||
|
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
||||||
|
|
||||||
|
interface TestIds {
|
||||||
|
readonly subjectUserId: string;
|
||||||
|
readonly otherUserId: string;
|
||||||
|
readonly peerId: string;
|
||||||
|
readonly revokedPeerId: string;
|
||||||
|
readonly activeGrantId: string;
|
||||||
|
readonly revokedGrantId: string;
|
||||||
|
readonly subjectProjectId: string;
|
||||||
|
readonly subjectMissionId: string;
|
||||||
|
readonly otherProjectId: string;
|
||||||
|
readonly teamId: string;
|
||||||
|
readonly unauthorizedTeamId: string;
|
||||||
|
readonly teamProjectId: string;
|
||||||
|
readonly taskIds: readonly string[];
|
||||||
|
readonly excludedTaskIds: readonly string[];
|
||||||
|
readonly subjectNoteId: string;
|
||||||
|
readonly otherUserNoteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemToDer(pem: string): Buffer {
|
||||||
|
return Buffer.from(
|
||||||
|
pem
|
||||||
|
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||||
|
.replace(/-----END CERTIFICATE-----/, '')
|
||||||
|
.replace(/\s+/g, ''),
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFederationRequest(certPem: string): FastifyRequest {
|
||||||
|
return {
|
||||||
|
raw: {
|
||||||
|
socket: {
|
||||||
|
getPeerCertificate: () => ({
|
||||||
|
raw: pemToDer(certPem),
|
||||||
|
serialNumber: CERT_SERIAL_HEX,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGuardContext(request: FastifyRequest): {
|
||||||
|
readonly context: ExecutionContext;
|
||||||
|
readonly sent: { statusCode?: number; payload?: unknown };
|
||||||
|
} {
|
||||||
|
const sent: { statusCode?: number; payload?: unknown } = {};
|
||||||
|
const reply = {
|
||||||
|
status: (statusCode: number) => {
|
||||||
|
sent.statusCode = statusCode;
|
||||||
|
return {
|
||||||
|
header: () => ({
|
||||||
|
send: (payload: unknown) => {
|
||||||
|
sent.payload = payload;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as FastifyReply;
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
getResponse: () => reply,
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
return { context, sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertUser(db: Db, id: string, label: string): Promise<void> {
|
||||||
|
await db.insert(users).values({
|
||||||
|
id,
|
||||||
|
name: `${RUN_ID}-${label}`,
|
||||||
|
email: `${RUN_ID}-${label}@federation-test.invalid`,
|
||||||
|
emailVerified: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedFixtures(db: Db): Promise<TestIds> {
|
||||||
|
const subjectUserId = `${RUN_ID}-subject`;
|
||||||
|
const otherUserId = `${RUN_ID}-other`;
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const revokedPeerId = crypto.randomUUID();
|
||||||
|
const activeGrantId = crypto.randomUUID();
|
||||||
|
const revokedGrantId = crypto.randomUUID();
|
||||||
|
const subjectProjectId = crypto.randomUUID();
|
||||||
|
const subjectMissionId = crypto.randomUUID();
|
||||||
|
const otherProjectId = crypto.randomUUID();
|
||||||
|
const teamId = crypto.randomUUID();
|
||||||
|
const unauthorizedTeamId = crypto.randomUUID();
|
||||||
|
const teamProjectId = crypto.randomUUID();
|
||||||
|
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||||
|
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||||
|
const subjectNoteId = crypto.randomUUID();
|
||||||
|
const otherUserNoteId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await insertUser(db, subjectUserId, 'subject');
|
||||||
|
await insertUser(db, otherUserId, 'other');
|
||||||
|
|
||||||
|
await db.insert(teams).values([
|
||||||
|
{
|
||||||
|
id: teamId,
|
||||||
|
name: `${RUN_ID} allowed team`,
|
||||||
|
slug: `${RUN_ID}-allowed-team`,
|
||||||
|
ownerId: subjectUserId,
|
||||||
|
managerId: subjectUserId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: unauthorizedTeamId,
|
||||||
|
name: `${RUN_ID} unauthorized team`,
|
||||||
|
slug: `${RUN_ID}-unauthorized-team`,
|
||||||
|
ownerId: otherUserId,
|
||||||
|
managerId: otherUserId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(teamMembers).values([
|
||||||
|
{ teamId, userId: subjectUserId, role: 'member' },
|
||||||
|
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(projects).values([
|
||||||
|
{
|
||||||
|
id: subjectProjectId,
|
||||||
|
name: `${RUN_ID} subject personal project`,
|
||||||
|
ownerType: 'user',
|
||||||
|
ownerId: subjectUserId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherProjectId,
|
||||||
|
name: `${RUN_ID} other personal project`,
|
||||||
|
ownerType: 'user',
|
||||||
|
ownerId: otherUserId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: teamProjectId,
|
||||||
|
name: `${RUN_ID} unauthorized team project`,
|
||||||
|
ownerType: 'team',
|
||||||
|
teamId: unauthorizedTeamId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(missions).values({
|
||||||
|
id: subjectMissionId,
|
||||||
|
name: `${RUN_ID} subject mission`,
|
||||||
|
projectId: subjectProjectId,
|
||||||
|
userId: subjectUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(tasks).values([
|
||||||
|
{
|
||||||
|
id: taskIds[0],
|
||||||
|
title: `${RUN_ID} visible task 1`,
|
||||||
|
missionId: subjectMissionId,
|
||||||
|
createdAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: taskIds[1],
|
||||||
|
title: `${RUN_ID} visible task 2`,
|
||||||
|
projectId: subjectProjectId,
|
||||||
|
createdAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: taskIds[2],
|
||||||
|
title: `${RUN_ID} visible task 3`,
|
||||||
|
projectId: subjectProjectId,
|
||||||
|
createdAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: excludedTaskIds[0],
|
||||||
|
title: `${RUN_ID} other user task`,
|
||||||
|
projectId: otherProjectId,
|
||||||
|
createdAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: excludedTaskIds[1],
|
||||||
|
title: `${RUN_ID} unauthorized team task`,
|
||||||
|
projectId: teamProjectId,
|
||||||
|
createdAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(missionTasks).values([
|
||||||
|
{
|
||||||
|
id: subjectNoteId,
|
||||||
|
missionId: subjectMissionId,
|
||||||
|
userId: subjectUserId,
|
||||||
|
notes: `${RUN_ID} subject visible note`,
|
||||||
|
createdAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherUserNoteId,
|
||||||
|
missionId: subjectMissionId,
|
||||||
|
userId: otherUserId,
|
||||||
|
notes: `${RUN_ID} other user note on subject mission`,
|
||||||
|
createdAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(federationPeers).values([
|
||||||
|
{
|
||||||
|
id: peerId,
|
||||||
|
commonName: `${RUN_ID}-active-peer`,
|
||||||
|
displayName: `${RUN_ID} Active Peer`,
|
||||||
|
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||||
|
certSerial: CERT_SERIAL_HEX,
|
||||||
|
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||||
|
state: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: revokedPeerId,
|
||||||
|
commonName: `${RUN_ID}-revoked-peer`,
|
||||||
|
displayName: `${RUN_ID} Revoked Peer`,
|
||||||
|
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||||
|
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
|
||||||
|
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||||
|
state: 'active',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(federationGrants).values([
|
||||||
|
{
|
||||||
|
id: activeGrantId,
|
||||||
|
peerId,
|
||||||
|
subjectUserId,
|
||||||
|
status: 'active',
|
||||||
|
scope: {
|
||||||
|
resources: ['tasks', 'notes'],
|
||||||
|
excluded_resources: [],
|
||||||
|
filters: {
|
||||||
|
tasks: { include_personal: true, include_teams: [] },
|
||||||
|
notes: { include_personal: true, include_teams: [] },
|
||||||
|
},
|
||||||
|
max_rows_per_query: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: revokedGrantId,
|
||||||
|
peerId,
|
||||||
|
subjectUserId,
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: new Date(),
|
||||||
|
revokedReason: `${RUN_ID} revoked grant fixture`,
|
||||||
|
scope: {
|
||||||
|
resources: ['tasks'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subjectUserId,
|
||||||
|
otherUserId,
|
||||||
|
peerId,
|
||||||
|
revokedPeerId,
|
||||||
|
activeGrantId,
|
||||||
|
revokedGrantId,
|
||||||
|
subjectProjectId,
|
||||||
|
subjectMissionId,
|
||||||
|
otherProjectId,
|
||||||
|
teamId,
|
||||||
|
unauthorizedTeamId,
|
||||||
|
teamProjectId,
|
||||||
|
taskIds,
|
||||||
|
excludedTaskIds,
|
||||||
|
subjectNoteId,
|
||||||
|
otherUserNoteId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
|
||||||
|
if (!ids) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(missionTasks)
|
||||||
|
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(tasks)
|
||||||
|
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(missions)
|
||||||
|
.where(eq(missions.id, ids.subjectMissionId))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(projects)
|
||||||
|
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(teamMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(teamMembers.userId, ids.subjectUserId),
|
||||||
|
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(teams)
|
||||||
|
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(federationGrants)
|
||||||
|
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(federationPeers)
|
||||||
|
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(users)
|
||||||
|
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
|
||||||
|
let handle: DbHandle;
|
||||||
|
let db: Db;
|
||||||
|
let moduleRef: TestingModule;
|
||||||
|
let guard: FederationAuthGuard;
|
||||||
|
let listController: ListController;
|
||||||
|
let ids: TestIds | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
handle = createDb(PG_URL);
|
||||||
|
db = handle.db;
|
||||||
|
ids = await seedFixtures(db);
|
||||||
|
|
||||||
|
moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [ListController],
|
||||||
|
providers: [
|
||||||
|
{ provide: DB, useValue: db },
|
||||||
|
GrantsService,
|
||||||
|
FederationAuthGuard,
|
||||||
|
FederationScopeService,
|
||||||
|
FederationListQueryService,
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
guard = moduleRef.get(FederationAuthGuard);
|
||||||
|
listController = moduleRef.get(ListController);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||||
|
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||||
|
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
|
||||||
|
const malformedOidCert = await makeSelfSignedCert();
|
||||||
|
const request = makeFederationRequest(malformedOidCert);
|
||||||
|
const { context, sent } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||||
|
expect(sent.statusCode).toBe(401);
|
||||||
|
expect(sent.payload).toMatchObject({
|
||||||
|
error: {
|
||||||
|
code: 'unauthorized',
|
||||||
|
message: expect.stringContaining('missing required OID'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(request.federationContext).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
|
||||||
|
expect(ids).toBeDefined();
|
||||||
|
const revokedCert = await makeMosaicIssuedCert({
|
||||||
|
grantId: ids!.revokedGrantId,
|
||||||
|
subjectUserId: ids!.subjectUserId,
|
||||||
|
});
|
||||||
|
const request = makeFederationRequest(revokedCert);
|
||||||
|
const { context, sent } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||||
|
expect(sent.statusCode).toBe(403);
|
||||||
|
expect(sent.payload).toMatchObject({
|
||||||
|
error: {
|
||||||
|
code: 'forbidden',
|
||||||
|
message: 'Federation access denied',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(request.federationContext).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
|
||||||
|
expect(ids).toBeDefined();
|
||||||
|
const activeCert = await makeMosaicIssuedCert({
|
||||||
|
grantId: ids!.activeGrantId,
|
||||||
|
subjectUserId: ids!.subjectUserId,
|
||||||
|
});
|
||||||
|
const request = makeFederationRequest(activeCert);
|
||||||
|
const { context } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||||
|
|
||||||
|
const response = await listController.list('tasks', request, { limit: 100 });
|
||||||
|
const returnedIds = response.items.map((item) => item['id']);
|
||||||
|
|
||||||
|
expect(response.items).toHaveLength(2);
|
||||||
|
expect(response._truncated).toBe(true);
|
||||||
|
expect(response.nextCursor).toEqual(expect.any(String));
|
||||||
|
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
|
||||||
|
expect(returnedIds).not.toContain(ids!.taskIds[2]);
|
||||||
|
for (const excludedId of ids!.excludedTaskIds) {
|
||||||
|
expect(returnedIds).not.toContain(excludedId);
|
||||||
|
}
|
||||||
|
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes another user mission task notes on the same authorized mission', async () => {
|
||||||
|
expect(ids).toBeDefined();
|
||||||
|
const activeCert = await makeMosaicIssuedCert({
|
||||||
|
grantId: ids!.activeGrantId,
|
||||||
|
subjectUserId: ids!.subjectUserId,
|
||||||
|
});
|
||||||
|
const request = makeFederationRequest(activeCert);
|
||||||
|
const { context } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||||
|
|
||||||
|
const response = await listController.list('notes', request, { limit: 10 });
|
||||||
|
const returnedIds = response.items.map((item) => item['id']);
|
||||||
|
|
||||||
|
expect(returnedIds).toEqual([ids!.subjectNoteId]);
|
||||||
|
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
|
||||||
|
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for unsupported list resources', async () => {
|
||||||
|
expect(ids).toBeDefined();
|
||||||
|
const activeCert = await makeMosaicIssuedCert({
|
||||||
|
grantId: ids!.activeGrantId,
|
||||||
|
subjectUserId: ids!.subjectUserId,
|
||||||
|
});
|
||||||
|
const request = makeFederationRequest(activeCert);
|
||||||
|
const { context } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||||
|
|
||||||
|
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'scope_violation',
|
||||||
|
message: 'Requested federation resource is not supported',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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',
|
||||||
|
agentConfigId: 'agent-config-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',
|
||||||
|
agentConfigId: 'agent-config-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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
||||||
import { sql, type Db } from '@mosaicstack/db';
|
import { sql, type Db } from '@mosaicstack/db';
|
||||||
import { createQueue } from '@mosaicstack/queue';
|
import { createQueue } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { ProviderService } from '../agent/provider.service.js';
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import { AdminGuard } from './admin.guard.js';
|
import { AdminGuard } from './admin.guard.js';
|
||||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||||
|
|
||||||
@@ -14,6 +16,9 @@ export class AdminHealthController {
|
|||||||
@Inject(DB) private readonly db: Db,
|
@Inject(DB) private readonly db: Db,
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(MOSAIC_CONFIG)
|
||||||
|
private readonly mosaicConfig: MosaicConfig | null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -55,6 +60,14 @@ export class AdminHealthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkCache(): Promise<ServiceStatusDto> {
|
private async checkCache(): Promise<ServiceStatusDto> {
|
||||||
|
// On Local tier there is no Redis. The cache is intentionally absent, which
|
||||||
|
// is a healthy state for this tier — report 'ok' rather than opening a new
|
||||||
|
// ioredis connection on every admin health check (which would spam
|
||||||
|
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
||||||
|
// signals "no cache backend to measure" for this tier.
|
||||||
|
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||||
|
return { status: 'ok', latencyMs: 0 };
|
||||||
|
}
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const handle = createQueue();
|
const handle = createQueue();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,18 +12,24 @@ type AgentServiceInternals = {
|
|||||||
creating: Map<string, Promise<AgentSession>>;
|
creating: Map<string, Promise<AgentSession>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeService(): AgentService {
|
function makeService(operatorMemory: unknown = null): AgentService {
|
||||||
return new AgentService(
|
return new AgentService(
|
||||||
{} as never,
|
{
|
||||||
|
getDefaultModel: vi.fn(() => null),
|
||||||
|
getRegistry: vi.fn(() => ({})),
|
||||||
|
findModel: vi.fn(),
|
||||||
|
listAvailableModels: vi.fn(() => []),
|
||||||
|
} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ available: false } as never,
|
{ available: false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||||
{} as never,
|
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
|
operatorMemory as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +115,18 @@ describe('AgentService owner/tenant scope enforcement', () => {
|
|||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
|
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
|
||||||
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
|
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
|
||||||
|
await service.prompt(CONVERSATION_ID, '', OWNER_SCOPE, [
|
||||||
|
{
|
||||||
|
id: 'attachment-001',
|
||||||
|
name: 'diagram.png',
|
||||||
|
url: 'https://cdn.example.test/diagram.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(session.piSession.prompt).toHaveBeenLastCalledWith(
|
||||||
|
'\n\n[Untrusted channel attachments]\n' +
|
||||||
|
'{"id":"attachment-001","name":"diagram.png","mimeType":"image/png","url":"https://cdn.example.test/diagram.png"}',
|
||||||
|
);
|
||||||
|
|
||||||
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
@@ -120,6 +138,37 @@ describe('AgentService owner/tenant scope enforcement', () => {
|
|||||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
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 () => {
|
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
||||||
const service = makeService();
|
const service = makeService();
|
||||||
const session = makeSession();
|
const session = makeSession();
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ import type {
|
|||||||
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
||||||
import {
|
import {
|
||||||
|
RuntimeProviderAuditService,
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
type RuntimeAuditEvent,
|
type RuntimeAuditEvent,
|
||||||
type RuntimeAuditSink,
|
type RuntimeAuditSink,
|
||||||
type RuntimeApprovalVerifier,
|
type RuntimeApprovalVerifier,
|
||||||
} from '../runtime-provider-registry.service.js';
|
} 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 OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
|
||||||
const CONTEXT = {
|
const CONTEXT = {
|
||||||
actorScope: OWNER_SCOPE,
|
actorScope: OWNER_SCOPE,
|
||||||
@@ -34,6 +37,7 @@ class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
|||||||
readonly sentMessages: RuntimeMessage[] = [];
|
readonly sentMessages: RuntimeMessage[] = [];
|
||||||
terminateCalls = 0;
|
terminateCalls = 0;
|
||||||
throwAfterSend = false;
|
throwAfterSend = false;
|
||||||
|
throwAuthorization = false;
|
||||||
|
|
||||||
constructor(private readonly supported: RuntimeCapability[]) {}
|
constructor(private readonly supported: RuntimeCapability[]) {}
|
||||||
|
|
||||||
@@ -73,6 +77,9 @@ class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.receivedScopes.push(scope);
|
this.receivedScopes.push(scope);
|
||||||
this.sentMessages.push(message);
|
this.sentMessages.push(message);
|
||||||
|
if (this.throwAuthorization) {
|
||||||
|
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
|
||||||
|
}
|
||||||
if (this.throwAfterSend) {
|
if (this.throwAfterSend) {
|
||||||
throw new Error('provider acknowledgement failed');
|
throw new Error('provider acknowledgement failed');
|
||||||
}
|
}
|
||||||
@@ -159,20 +166,47 @@ describe('RuntimeProviderService security boundary', (): void => {
|
|||||||
correlationId: CONTEXT.correlationId,
|
correlationId: CONTEXT.correlationId,
|
||||||
});
|
});
|
||||||
expect(Object.isFrozen(providerScope)).toBe(true);
|
expect(Object.isFrozen(providerScope)).toBe(true);
|
||||||
expect(audit.events).toContainEqual({
|
expect(audit.events).toContainEqual(
|
||||||
providerId: 'fleet',
|
expect.objectContaining({
|
||||||
operation: 'session.send',
|
providerId: 'fleet',
|
||||||
outcome: 'succeeded',
|
operation: 'session.send',
|
||||||
actorId: OWNER_SCOPE.userId,
|
outcome: 'succeeded',
|
||||||
tenantId: OWNER_SCOPE.tenantId,
|
actorId: OWNER_SCOPE.userId,
|
||||||
channelId: CONTEXT.channelId,
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
correlationId: CONTEXT.correlationId,
|
channelId: CONTEXT.channelId,
|
||||||
resourceId: 'session-1',
|
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('hello');
|
||||||
expect(JSON.stringify(audit.events)).not.toContain('key-1');
|
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> => {
|
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
|
||||||
const provider = new RecordingRuntimeProvider([]);
|
const provider = new RecordingRuntimeProvider([]);
|
||||||
const service = makeService(provider);
|
const service = makeService(provider);
|
||||||
@@ -191,12 +225,14 @@ describe('RuntimeProviderService security boundary', (): void => {
|
|||||||
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
|
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
|
||||||
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
||||||
const approval = new DenyingApprovalVerifier();
|
const approval = new DenyingApprovalVerifier();
|
||||||
const service = makeService(provider, new RecordingAuditSink(), approval);
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit, approval);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
|
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
|
||||||
).rejects.toThrow(/approval denied/);
|
).rejects.toThrow(/approval denied/);
|
||||||
expect(provider.terminateCalls).toBe(0);
|
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> => {
|
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
|
||||||
@@ -213,6 +249,7 @@ describe('RuntimeProviderService security boundary', (): void => {
|
|||||||
tenantId: OWNER_SCOPE.tenantId,
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
channelId: CONTEXT.channelId,
|
channelId: CONTEXT.channelId,
|
||||||
correlationId: CONTEXT.correlationId,
|
correlationId: CONTEXT.correlationId,
|
||||||
|
agentName: process.env['MOSAIC_AGENT_NAME'],
|
||||||
});
|
});
|
||||||
expect(provider.terminateCalls).toBe(1);
|
expect(provider.terminateCalls).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -256,6 +293,54 @@ describe('RuntimeProviderService security boundary', (): void => {
|
|||||||
'requested',
|
'requested',
|
||||||
'failed',
|
'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> => {
|
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
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';
|
||||||
@@ -9,47 +9,79 @@ 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 { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||||
|
import {
|
||||||
|
CONNECTOR_LEASE_POLICY,
|
||||||
|
ConnectorLeaseService,
|
||||||
|
DenyConnectorLeasePolicy,
|
||||||
|
} from './connector-lease.service.js';
|
||||||
import {
|
import {
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
DenyRuntimeApprovalVerifier,
|
|
||||||
RUNTIME_APPROVAL_VERIFIER,
|
RUNTIME_APPROVAL_VERIFIER,
|
||||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
RuntimeProviderAuditService,
|
RuntimeProviderAuditService,
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
} from './runtime-provider-registry.service.js';
|
} 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,
|
||||||
|
ConnectorLeaseRepository,
|
||||||
|
DenyConnectorLeasePolicy,
|
||||||
|
{
|
||||||
|
provide: CONNECTOR_LEASE_POLICY,
|
||||||
|
useExisting: DenyConnectorLeasePolicy,
|
||||||
|
},
|
||||||
|
ConnectorLeaseService,
|
||||||
{
|
{
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
|
useFactory: createGatewayRuntimeProviderRegistry,
|
||||||
},
|
},
|
||||||
RuntimeProviderAuditService,
|
RuntimeProviderAuditService,
|
||||||
{
|
{
|
||||||
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
useExisting: RuntimeProviderAuditService,
|
useExisting: RuntimeProviderAuditService,
|
||||||
},
|
},
|
||||||
DenyRuntimeApprovalVerifier,
|
|
||||||
{
|
{
|
||||||
provide: RUNTIME_APPROVAL_VERIFIER,
|
provide: RUNTIME_APPROVAL_VERIFIER,
|
||||||
useExisting: DenyRuntimeApprovalVerifier,
|
useExisting: CommandRuntimeApprovalVerifier,
|
||||||
},
|
},
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
AgentService,
|
AgentService,
|
||||||
],
|
],
|
||||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
controllers: [
|
||||||
|
ProvidersController,
|
||||||
|
SessionsController,
|
||||||
|
AgentConfigsController,
|
||||||
|
InteractionController,
|
||||||
|
RoutingController,
|
||||||
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AgentService,
|
AgentService,
|
||||||
ProviderService,
|
ProviderService,
|
||||||
@@ -57,7 +89,9 @@ import {
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
|
DurableSessionService,
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
|
ConnectorLeaseService,
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ 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 { ChannelAttachmentDto } from '@mosaicstack/types';
|
||||||
|
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';
|
||||||
@@ -42,6 +44,8 @@ export interface ConversationHistoryMessage {
|
|||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
/** Validated, URI-referenced channel attachments preserved on session resume. */
|
||||||
|
attachments?: readonly ChannelAttachmentDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSessionOptions {
|
export interface AgentSessionOptions {
|
||||||
@@ -135,6 +139,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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,6 +153,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),
|
||||||
@@ -154,6 +162,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),
|
||||||
@@ -228,6 +239,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(
|
||||||
@@ -267,7 +279,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();
|
||||||
@@ -362,7 +382,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
userId: mergedOptions?.userId,
|
||||||
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
|
tenantId: sessionTenantId,
|
||||||
agentConfigId: mergedOptions?.agentConfigId,
|
agentConfigId: mergedOptions?.agentConfigId,
|
||||||
agentName: resolvedAgentName,
|
agentName: resolvedAgentName,
|
||||||
metrics: {
|
metrics: {
|
||||||
@@ -411,7 +431,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
const formatMessage = (msg: ConversationHistoryMessage): string => {
|
const formatMessage = (msg: ConversationHistoryMessage): string => {
|
||||||
const roleLabel =
|
const roleLabel =
|
||||||
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
|
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
|
||||||
return `**${roleLabel}:** ${msg.content}`;
|
return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatted = history.map((msg) => formatMessage(msg));
|
const formatted = history.map((msg) => formatMessage(msg));
|
||||||
@@ -470,6 +490,21 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private attachmentContext(attachments: readonly ChannelAttachmentDto[]): string {
|
||||||
|
if (attachments.length === 0) return '';
|
||||||
|
return `\n\n[Untrusted channel attachments]\n${attachments
|
||||||
|
.map((attachment: ChannelAttachmentDto): string =>
|
||||||
|
JSON.stringify({
|
||||||
|
id: attachment.id,
|
||||||
|
name: attachment.name,
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
url: attachment.url,
|
||||||
|
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
private resolveModel(options?: AgentSessionOptions) {
|
private resolveModel(options?: AgentSessionOptions) {
|
||||||
if (!options?.provider && !options?.modelId) {
|
if (!options?.provider && !options?.modelId) {
|
||||||
return this.providerService.getDefaultModel() ?? null;
|
return this.providerService.getDefaultModel() ?? null;
|
||||||
@@ -656,7 +691,19 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
session.channels.delete(channel);
|
session.channels.delete(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
|
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void>;
|
||||||
|
async prompt(
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
attachments: readonly ChannelAttachmentDto[] | undefined,
|
||||||
|
): Promise<void>;
|
||||||
|
async prompt(
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
attachments: readonly ChannelAttachmentDto[] = [],
|
||||||
|
): 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}`);
|
||||||
@@ -664,12 +711,16 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
this.assertSessionScope(session, scope);
|
this.assertSessionScope(session, scope);
|
||||||
session.promptCount += 1;
|
session.promptCount += 1;
|
||||||
|
|
||||||
|
// Channel attachments are untrusted URI references. Preserve exact,
|
||||||
|
// authenticated metadata for the agent without treating it as authority.
|
||||||
|
const attachmentContext = this.attachmentContext(attachments);
|
||||||
|
|
||||||
// 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}${attachmentContext}`;
|
||||||
if (this.systemOverride) {
|
if (this.systemOverride) {
|
||||||
const override = await this.systemOverride.get(sessionId, scope);
|
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${effectiveMessage}`;
|
||||||
await this.systemOverride.renew(sessionId, scope);
|
await this.systemOverride.renew(sessionId, scope);
|
||||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Test, type TestingModule } from '@nestjs/testing';
|
||||||
|
import {
|
||||||
|
connectorLeaseAuditLog,
|
||||||
|
createPgliteDb,
|
||||||
|
eq,
|
||||||
|
runPgliteMigrations,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import type { ConnectorExecutionContext, FencedConnectorAdapter } from '@mosaicstack/types';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||||
|
import {
|
||||||
|
CONNECTOR_LEASE_POLICY,
|
||||||
|
ConnectorLeaseService,
|
||||||
|
type ConnectorLeasePolicy,
|
||||||
|
type ConnectorLeasePolicySubject,
|
||||||
|
} from './connector-lease.service.js';
|
||||||
|
|
||||||
|
const authorize = vi.fn().mockResolvedValue(true);
|
||||||
|
const policy: ConnectorLeasePolicy = { authorize };
|
||||||
|
const context = {
|
||||||
|
actorScope: { userId: 'operator-a', tenantId: 'tenant-a' },
|
||||||
|
correlationId: 'correlation-acquire',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('gateway connector lease fencing integration', (): void => {
|
||||||
|
let dataDir: string;
|
||||||
|
let handle: DbHandle;
|
||||||
|
let moduleRef: TestingModule;
|
||||||
|
let service: ConnectorLeaseService;
|
||||||
|
let repository: ConnectorLeaseRepository;
|
||||||
|
|
||||||
|
beforeAll(async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-07-14T17:00:00.000Z'));
|
||||||
|
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-connector-lease-'));
|
||||||
|
handle = createPgliteDb(dataDir);
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ConnectorLeaseRepository,
|
||||||
|
ConnectorLeaseService,
|
||||||
|
{ provide: DB, useValue: handle.db },
|
||||||
|
{ provide: CONNECTOR_LEASE_POLICY, useValue: policy },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(ConnectorLeaseService);
|
||||||
|
repository = moduleRef.get(ConnectorLeaseRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async (): Promise<void> => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await moduleRef.close();
|
||||||
|
await handle.close();
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives tenant authority at the gateway and validates a grant before side effects', async (): Promise<void> => {
|
||||||
|
const lease = await service.acquire(
|
||||||
|
{
|
||||||
|
logicalAgentId: 'Mos',
|
||||||
|
bindingId: 'operator-chat',
|
||||||
|
connectorId: 'pi-worker-a',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const grant = await service.issueGrant(
|
||||||
|
{ lease, scopes: ['runtime.send'], ttlMs: 30_000 },
|
||||||
|
{ ...context, correlationId: 'correlation-grant' },
|
||||||
|
);
|
||||||
|
const execute = vi.fn(async (_message: string, leaseContext: ConnectorExecutionContext) => {
|
||||||
|
return leaseContext.leaseEpoch;
|
||||||
|
});
|
||||||
|
const adapter: FencedConnectorAdapter<string, string> = { execute };
|
||||||
|
|
||||||
|
await expect(service.executeGrant(grant, 'runtime.send', 'hello', adapter)).resolves.toBe('1');
|
||||||
|
expect(execute).toHaveBeenCalledOnce();
|
||||||
|
expect(authorize).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'grant.issue',
|
||||||
|
requestedScopes: ['runtime.send'],
|
||||||
|
requestedTtlMs: 30_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(execute.mock.calls[0]?.[1]).toMatchObject({
|
||||||
|
identity: { tenantId: 'tenant-a', logicalAgentId: 'mos' },
|
||||||
|
bindingId: 'operator-chat',
|
||||||
|
connectorId: 'pi-worker-a',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes lease-derived policy subjects before authorization', async (): Promise<void> => {
|
||||||
|
const lease = await service.acquire(
|
||||||
|
{
|
||||||
|
logicalAgentId: 'mos',
|
||||||
|
bindingId: 'operator-chat-policy',
|
||||||
|
connectorId: 'pi-worker-a',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
},
|
||||||
|
{ ...context, correlationId: 'correlation-policy-setup' },
|
||||||
|
);
|
||||||
|
const aliasedLease = {
|
||||||
|
...lease,
|
||||||
|
identity: { ...lease.identity, logicalAgentId: ' MOS ' },
|
||||||
|
bindingId: ' Operator-Chat-Policy ',
|
||||||
|
connectorId: ' PI-Worker-A ',
|
||||||
|
scopes: [' Runtime.Send '],
|
||||||
|
leaseEpoch: `00${lease.leaseEpoch}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.heartbeat(aliasedLease, 30_000, {
|
||||||
|
...context,
|
||||||
|
correlationId: 'correlation-policy-heartbeat',
|
||||||
|
});
|
||||||
|
expect(authorize).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'lease.heartbeat',
|
||||||
|
logicalAgentId: 'mos',
|
||||||
|
bindingId: 'operator-chat-policy',
|
||||||
|
connectorId: 'pi-worker-a',
|
||||||
|
requestedScopes: ['runtime.send'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.issueGrant(
|
||||||
|
{ lease: aliasedLease, scopes: [' Runtime.Send '], ttlMs: 1_000 },
|
||||||
|
{ ...context, correlationId: 'correlation-policy-grant' },
|
||||||
|
);
|
||||||
|
expect(authorize).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'grant.issue',
|
||||||
|
logicalAgentId: 'mos',
|
||||||
|
bindingId: 'operator-chat-policy',
|
||||||
|
connectorId: 'pi-worker-a',
|
||||||
|
requestedScopes: ['runtime.send'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.release(aliasedLease, {
|
||||||
|
...context,
|
||||||
|
correlationId: 'correlation-policy-release',
|
||||||
|
});
|
||||||
|
expect(authorize).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'lease.release',
|
||||||
|
logicalAgentId: 'mos',
|
||||||
|
bindingId: 'operator-chat-policy',
|
||||||
|
connectorId: 'pi-worker-a',
|
||||||
|
requestedScopes: ['runtime.send'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies stale, forged, expired, cross-tenant, and cross-binding grants before effects', async (): Promise<void> => {
|
||||||
|
const bindingId = 'operator-chat-denials';
|
||||||
|
const current = await service.acquire(
|
||||||
|
{
|
||||||
|
logicalAgentId: 'mos',
|
||||||
|
bindingId,
|
||||||
|
connectorId: 'pi-worker-a',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
},
|
||||||
|
{ ...context, correlationId: 'correlation-denial-setup' },
|
||||||
|
);
|
||||||
|
const stale = await service.issueGrant(
|
||||||
|
{ lease: current, scopes: ['runtime.send'], ttlMs: 30_000 },
|
||||||
|
{ ...context, correlationId: 'correlation-stale' },
|
||||||
|
);
|
||||||
|
await service.takeover(
|
||||||
|
{
|
||||||
|
logicalAgentId: 'mos',
|
||||||
|
bindingId,
|
||||||
|
connectorId: 'pi-worker-b',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
expectedEpoch: current.leaseEpoch,
|
||||||
|
},
|
||||||
|
{ ...context, correlationId: 'correlation-takeover' },
|
||||||
|
);
|
||||||
|
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
|
await expect(service.executeGrant(stale, 'runtime.send', undefined, adapter)).rejects.toThrow();
|
||||||
|
|
||||||
|
const active = await service.current('mos', bindingId, context);
|
||||||
|
if (!active) throw new Error('active lease fixture is unavailable');
|
||||||
|
const grant = await service.issueGrant(
|
||||||
|
{ lease: active, scopes: ['runtime.send'], ttlMs: 1_000 },
|
||||||
|
{ ...context, correlationId: 'correlation-active' },
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.executeGrant({ ...grant }, 'runtime.send', undefined, adapter),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
service.executeGrant(
|
||||||
|
{ ...grant, bindingId: 'other-binding' },
|
||||||
|
'runtime.send',
|
||||||
|
undefined,
|
||||||
|
adapter,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
service.issueGrant(
|
||||||
|
{ lease: active, scopes: ['runtime.send'], ttlMs: 30_000 },
|
||||||
|
{
|
||||||
|
actorScope: { userId: 'operator-b', tenantId: 'tenant-b' },
|
||||||
|
correlationId: 'correlation-cross-tenant',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
const crossTenantAudit = await handle.db
|
||||||
|
.select()
|
||||||
|
.from(connectorLeaseAuditLog)
|
||||||
|
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-cross-tenant'));
|
||||||
|
expect(crossTenantAudit).toHaveLength(1);
|
||||||
|
expect(crossTenantAudit[0]).toMatchObject({
|
||||||
|
tenantId: 'tenant-b',
|
||||||
|
logicalAgentId: 'untrusted',
|
||||||
|
bindingId: 'untrusted',
|
||||||
|
connectorId: 'untrusted',
|
||||||
|
reason: 'policy_denied',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-07-14T17:00:02.000Z'));
|
||||||
|
await expect(service.executeGrant(grant, 'runtime.send', undefined, adapter)).rejects.toThrow();
|
||||||
|
expect(adapter.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects submitted lifecycle scopes that differ from durable authority before policy or mutation', async (): Promise<void> => {
|
||||||
|
authorize.mockResolvedValue(true);
|
||||||
|
const heartbeatLease = await service.acquire(
|
||||||
|
{
|
||||||
|
logicalAgentId: 'mos',
|
||||||
|
bindingId: 'operator-chat-heartbeat-scope',
|
||||||
|
connectorId: 'pi-worker-a',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
},
|
||||||
|
{ ...context, correlationId: 'correlation-heartbeat-scope-setup' },
|
||||||
|
);
|
||||||
|
const releaseLease = await service.acquire(
|
||||||
|
{
|
||||||
|
logicalAgentId: 'mos',
|
||||||
|
bindingId: 'operator-chat-release-scope',
|
||||||
|
connectorId: 'pi-worker-a',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
},
|
||||||
|
{ ...context, correlationId: 'correlation-release-scope-setup' },
|
||||||
|
);
|
||||||
|
const forgedHeartbeat = { ...heartbeatLease, scopes: ['tool.execute'] };
|
||||||
|
const forgedRelease = { ...releaseLease, scopes: ['tool.execute'] };
|
||||||
|
|
||||||
|
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
|
||||||
|
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'tool.execute';
|
||||||
|
});
|
||||||
|
authorize.mockClear();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.heartbeat(forgedHeartbeat, 30_000, {
|
||||||
|
...context,
|
||||||
|
correlationId: 'correlation-heartbeat-scope-forgery',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Connector authority policy denied');
|
||||||
|
await expect(
|
||||||
|
service.release(forgedRelease, {
|
||||||
|
...context,
|
||||||
|
correlationId: 'correlation-release-scope-forgery',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Connector authority policy denied');
|
||||||
|
expect(authorize).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const currentHeartbeat = await repository.findCurrent({
|
||||||
|
identity: heartbeatLease.identity,
|
||||||
|
bindingId: heartbeatLease.bindingId,
|
||||||
|
});
|
||||||
|
const currentRelease = await repository.findCurrent({
|
||||||
|
identity: releaseLease.identity,
|
||||||
|
bindingId: releaseLease.bindingId,
|
||||||
|
});
|
||||||
|
expect(currentHeartbeat).toMatchObject({
|
||||||
|
leaseId: heartbeatLease.leaseId,
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
heartbeatAt: heartbeatLease.heartbeatAt,
|
||||||
|
expiresAt: heartbeatLease.expiresAt,
|
||||||
|
});
|
||||||
|
expect(currentRelease).toMatchObject({
|
||||||
|
leaseId: releaseLease.leaseId,
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
});
|
||||||
|
expect(currentRelease?.releasedAt).toBeUndefined();
|
||||||
|
|
||||||
|
const forgedAudits = await handle.db
|
||||||
|
.select()
|
||||||
|
.from(connectorLeaseAuditLog)
|
||||||
|
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-heartbeat-scope-forgery'));
|
||||||
|
expect(forgedAudits).toHaveLength(1);
|
||||||
|
expect(forgedAudits[0]).toMatchObject({
|
||||||
|
bindingId: heartbeatLease.bindingId,
|
||||||
|
connectorId: heartbeatLease.connectorId,
|
||||||
|
event: 'reject',
|
||||||
|
outcome: 'denied',
|
||||||
|
reason: 'policy_denied',
|
||||||
|
});
|
||||||
|
const forgedReleaseAudits = await handle.db
|
||||||
|
.select()
|
||||||
|
.from(connectorLeaseAuditLog)
|
||||||
|
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-release-scope-forgery'));
|
||||||
|
expect(forgedReleaseAudits).toHaveLength(1);
|
||||||
|
expect(forgedReleaseAudits[0]).toMatchObject({
|
||||||
|
bindingId: releaseLease.bindingId,
|
||||||
|
connectorId: releaseLease.connectorId,
|
||||||
|
event: 'reject',
|
||||||
|
outcome: 'denied',
|
||||||
|
reason: 'policy_denied',
|
||||||
|
});
|
||||||
|
|
||||||
|
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
|
||||||
|
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'runtime.send';
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.heartbeat(heartbeatLease, 30_000, {
|
||||||
|
...context,
|
||||||
|
correlationId: 'correlation-heartbeat-scope-canonical',
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ scopes: ['runtime.send'] });
|
||||||
|
await expect(
|
||||||
|
service.release(releaseLease, {
|
||||||
|
...context,
|
||||||
|
correlationId: 'correlation-release-scope-canonical',
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
connectorLeaseAuditLog,
|
||||||
|
createDb,
|
||||||
|
eq,
|
||||||
|
logicalAgentConnectorLeases,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { ConnectorLeaseCoordinator } from '@mosaicstack/agent';
|
||||||
|
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||||
|
|
||||||
|
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||||
|
const tenantId = `lease-test-${randomUUID()}`;
|
||||||
|
const identity = { tenantId, logicalAgentId: 'mos' } as const;
|
||||||
|
|
||||||
|
describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => {
|
||||||
|
let handle: DbHandle;
|
||||||
|
|
||||||
|
beforeAll((): void => {
|
||||||
|
handle = createDb(process.env['DATABASE_URL']);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async (): Promise<void> => {
|
||||||
|
if (!handle) return;
|
||||||
|
await handle.db
|
||||||
|
.delete(connectorLeaseAuditLog)
|
||||||
|
.where(eq(connectorLeaseAuditLog.tenantId, tenantId));
|
||||||
|
await handle.db
|
||||||
|
.delete(logicalAgentConnectorLeases)
|
||||||
|
.where(eq(logicalAgentConnectorLeases.tenantId, tenantId));
|
||||||
|
await handle.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise<void> => {
|
||||||
|
const command = {
|
||||||
|
identity,
|
||||||
|
bindingId: 'operator-chat',
|
||||||
|
scopes: ['runtime.send'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
} as const;
|
||||||
|
const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
||||||
|
const contenders = await Promise.allSettled([
|
||||||
|
firstCoordinator.acquire({
|
||||||
|
...command,
|
||||||
|
connectorId: 'connector-a',
|
||||||
|
correlationId: 'postgres-acquire-a',
|
||||||
|
}),
|
||||||
|
firstCoordinator.acquire({
|
||||||
|
...command,
|
||||||
|
connectorId: 'connector-b',
|
||||||
|
correlationId: 'postgres-acquire-b',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const acquired = contenders.find((result) => result.status === 'fulfilled');
|
||||||
|
if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won');
|
||||||
|
expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
|
||||||
|
await handle.close();
|
||||||
|
handle = createDb(process.env['DATABASE_URL']);
|
||||||
|
const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
||||||
|
const persisted = await reopened.current({ identity, bindingId: 'operator-chat' });
|
||||||
|
expect(persisted).toMatchObject({
|
||||||
|
leaseId: acquired.value.leaseId,
|
||||||
|
leaseEpoch: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const takeover = await reopened.takeover({
|
||||||
|
...command,
|
||||||
|
connectorId: 'connector-c',
|
||||||
|
correlationId: 'postgres-takeover',
|
||||||
|
expectedEpoch: acquired.value.leaseEpoch,
|
||||||
|
});
|
||||||
|
expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
connectorLeaseAuditLog,
|
||||||
|
createPgliteDb,
|
||||||
|
eq,
|
||||||
|
runPgliteMigrations,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent';
|
||||||
|
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||||
|
|
||||||
|
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
|
||||||
|
|
||||||
|
function acquireCommand(connectorId: string, correlationId: string) {
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
bindingId: 'operator-chat',
|
||||||
|
connectorId,
|
||||||
|
scopes: ['runtime.send', 'tool.execute'],
|
||||||
|
ttlMs: 60_000,
|
||||||
|
correlationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => {
|
||||||
|
let dataDir: string;
|
||||||
|
let handle: DbHandle;
|
||||||
|
let now: Date;
|
||||||
|
let coordinator: ConnectorLeaseCoordinator;
|
||||||
|
|
||||||
|
beforeEach(async (): Promise<void> => {
|
||||||
|
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-'));
|
||||||
|
handle = createPgliteDb(dataDir);
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
now = new Date('2026-07-14T17:00:00.000Z');
|
||||||
|
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
||||||
|
now: (): Date => now,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
await handle.close();
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only one concurrent contender to acquire a binding', async (): Promise<void> => {
|
||||||
|
const outcomes = await Promise.allSettled([
|
||||||
|
coordinator.acquire(acquireCommand('connector-a', 'correlation-a')),
|
||||||
|
coordinator.acquire(acquireCommand('connector-b', 'correlation-b')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
const rejected = outcomes.find((result) => result.status === 'rejected');
|
||||||
|
expect(rejected).toMatchObject({
|
||||||
|
reason: { code: 'lease_held' } satisfies Partial<ConnectorLeaseError>,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise<void> => {
|
||||||
|
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
coordinator.takeover({
|
||||||
|
...acquireCommand('connector-b', 'correlation-b'),
|
||||||
|
expectedEpoch: acquired.leaseEpoch,
|
||||||
|
}),
|
||||||
|
coordinator.takeover({
|
||||||
|
...acquireCommand('connector-c', 'correlation-c'),
|
||||||
|
expectedEpoch: acquired.leaseEpoch,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const winner = results.find((result) => result.status === 'fulfilled');
|
||||||
|
|
||||||
|
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2');
|
||||||
|
expect(results.find((result) => result.status === 'rejected')).toMatchObject({
|
||||||
|
reason: { code: 'cas_mismatch' } satisfies Partial<ConnectorLeaseError>,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('heartbeats and releases only the current connector epoch', async (): Promise<void> => {
|
||||||
|
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||||
|
now = new Date('2026-07-14T17:00:30.000Z');
|
||||||
|
const renewed = await coordinator.heartbeat({
|
||||||
|
lease: acquired,
|
||||||
|
ttlMs: 120_000,
|
||||||
|
correlationId: 'correlation-renew',
|
||||||
|
});
|
||||||
|
expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z');
|
||||||
|
|
||||||
|
await coordinator.release({ lease: renewed, correlationId: 'correlation-release' });
|
||||||
|
await expect(
|
||||||
|
coordinator.heartbeat({
|
||||||
|
lease: renewed,
|
||||||
|
ttlMs: 120_000,
|
||||||
|
correlationId: 'correlation-stale',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial<ConnectorLeaseError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise<void> => {
|
||||||
|
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||||
|
await handle.close();
|
||||||
|
|
||||||
|
now = new Date('2026-07-14T17:02:00.000Z');
|
||||||
|
handle = createPgliteDb(dataDir);
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
||||||
|
now: (): Date => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')),
|
||||||
|
).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial<ConnectorLeaseError>);
|
||||||
|
const recovered = await coordinator.takeover({
|
||||||
|
...acquireCommand('connector-b', 'correlation-takeover'),
|
||||||
|
expectedEpoch: acquired.leaseEpoch,
|
||||||
|
});
|
||||||
|
expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes credential-safe lifecycle and rejection audit records', async (): Promise<void> => {
|
||||||
|
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||||
|
await coordinator.heartbeat({
|
||||||
|
lease: acquired,
|
||||||
|
ttlMs: 60_000,
|
||||||
|
correlationId: 'correlation-renew',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')),
|
||||||
|
).rejects.toBeInstanceOf(ConnectorLeaseError);
|
||||||
|
|
||||||
|
const rows = await handle.db
|
||||||
|
.select()
|
||||||
|
.from(connectorLeaseAuditLog)
|
||||||
|
.where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId));
|
||||||
|
expect(rows.map((row) => row.event)).toEqual(
|
||||||
|
expect.arrayContaining(['acquire', 'renew', 'reject']),
|
||||||
|
);
|
||||||
|
const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown =>
|
||||||
|
typeof value === 'bigint' ? value.toString(10) : value,
|
||||||
|
);
|
||||||
|
expect(serialized).not.toContain('tool.execute');
|
||||||
|
expect(serialized).not.toContain('runtime.send');
|
||||||
|
expect(serialized).not.toMatch(/token|secret|credential/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
connectorLeaseAuditLog,
|
||||||
|
eq,
|
||||||
|
gt,
|
||||||
|
isNull,
|
||||||
|
logicalAgentConnectorLeases,
|
||||||
|
sql,
|
||||||
|
type Db,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { ConnectorLeaseError } from '@mosaicstack/agent';
|
||||||
|
import type {
|
||||||
|
ConnectorLease,
|
||||||
|
ConnectorLeaseAcquireMutation,
|
||||||
|
ConnectorLeaseAuditEvent,
|
||||||
|
ConnectorLeaseHeartbeatMutation,
|
||||||
|
ConnectorLeaseRejectReason,
|
||||||
|
ConnectorLeaseReleaseMutation,
|
||||||
|
ConnectorLeaseStore,
|
||||||
|
ConnectorLeaseTakeoverMutation,
|
||||||
|
LogicalAgentBinding,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
|
interface SuccessfulMutation {
|
||||||
|
readonly ok: true;
|
||||||
|
readonly lease: ConnectorLease;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FailedMutation {
|
||||||
|
readonly ok: false;
|
||||||
|
readonly reason: ConnectorLeaseRejectReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutationResult = SuccessfulMutation | FailedMutation;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConnectorLeaseRepository implements ConnectorLeaseStore {
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
async acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease> {
|
||||||
|
const result: MutationResult = await this.db.transaction(
|
||||||
|
async (tx): Promise<MutationResult> => {
|
||||||
|
const inserted = await tx
|
||||||
|
.insert(logicalAgentConnectorLeases)
|
||||||
|
.values({
|
||||||
|
leaseId: input.leaseId,
|
||||||
|
tenantId: input.identity.tenantId,
|
||||||
|
logicalAgentId: input.identity.logicalAgentId,
|
||||||
|
bindingId: input.bindingId,
|
||||||
|
connectorId: input.connectorId,
|
||||||
|
scopes: [...input.scopes],
|
||||||
|
leaseEpoch: 1n,
|
||||||
|
acquiredAt: new Date(input.now),
|
||||||
|
heartbeatAt: new Date(input.now),
|
||||||
|
expiresAt: new Date(input.expiresAt),
|
||||||
|
updatedAt: new Date(input.now),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
const row = inserted[0];
|
||||||
|
if (row) {
|
||||||
|
const lease = toLease(row);
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, lease, 'acquire'));
|
||||||
|
return { ok: true, lease };
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await findRow(tx, input);
|
||||||
|
if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||||
|
}
|
||||||
|
const reason: ConnectorLeaseRejectReason =
|
||||||
|
current && (current.releasedAt || current.expiresAt <= new Date(input.now))
|
||||||
|
? 'takeover_required'
|
||||||
|
: 'lease_held';
|
||||||
|
await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason));
|
||||||
|
return { ok: false, reason };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unwrap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease> {
|
||||||
|
const result: MutationResult = await this.db.transaction(
|
||||||
|
async (tx): Promise<MutationResult> => {
|
||||||
|
const current = await findRow(tx, input);
|
||||||
|
if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) {
|
||||||
|
await insertAudit(
|
||||||
|
tx,
|
||||||
|
rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'),
|
||||||
|
);
|
||||||
|
return { ok: false, reason: 'cas_mismatch' };
|
||||||
|
}
|
||||||
|
if (current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||||
|
}
|
||||||
|
const updated = await tx
|
||||||
|
.update(logicalAgentConnectorLeases)
|
||||||
|
.set({
|
||||||
|
leaseId: input.leaseId,
|
||||||
|
connectorId: input.connectorId,
|
||||||
|
scopes: [...input.scopes],
|
||||||
|
leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`,
|
||||||
|
acquiredAt: new Date(input.now),
|
||||||
|
heartbeatAt: new Date(input.now),
|
||||||
|
expiresAt: new Date(input.expiresAt),
|
||||||
|
releasedAt: null,
|
||||||
|
updatedAt: new Date(input.now),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
bindingPredicate(input),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseId, current.leaseId),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
const row = updated[0];
|
||||||
|
if (!row) {
|
||||||
|
await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch'));
|
||||||
|
return { ok: false, reason: 'cas_mismatch' };
|
||||||
|
}
|
||||||
|
const lease = toLease(row);
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, lease, 'takeover'));
|
||||||
|
return { ok: true, lease };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unwrap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease> {
|
||||||
|
const result: MutationResult = await this.db.transaction(
|
||||||
|
async (tx): Promise<MutationResult> => {
|
||||||
|
const updated = await tx
|
||||||
|
.update(logicalAgentConnectorLeases)
|
||||||
|
.set({
|
||||||
|
heartbeatAt: new Date(input.now),
|
||||||
|
expiresAt: new Date(input.expiresAt),
|
||||||
|
updatedAt: new Date(input.now),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
bindingPredicate(input.lease),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
||||||
|
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
||||||
|
isNull(logicalAgentConnectorLeases.releasedAt),
|
||||||
|
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
const row = updated[0];
|
||||||
|
if (row) {
|
||||||
|
const lease = toLease(row);
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, lease, 'renew'));
|
||||||
|
return { ok: true, lease };
|
||||||
|
}
|
||||||
|
const current = await findRow(tx, input.lease);
|
||||||
|
const reason = classifyAuthorityFailure(
|
||||||
|
current ? toLease(current) : null,
|
||||||
|
input.lease,
|
||||||
|
input.now,
|
||||||
|
);
|
||||||
|
if (reason === 'lease_expired' && current) {
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||||
|
}
|
||||||
|
await insertAudit(
|
||||||
|
tx,
|
||||||
|
rejectionAudit(
|
||||||
|
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
||||||
|
current ? toLease(current) : null,
|
||||||
|
reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { ok: false, reason };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unwrap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async release(input: ConnectorLeaseReleaseMutation): Promise<void> {
|
||||||
|
const result: MutationResult = await this.db.transaction(
|
||||||
|
async (tx): Promise<MutationResult> => {
|
||||||
|
const updated = await tx
|
||||||
|
.update(logicalAgentConnectorLeases)
|
||||||
|
.set({
|
||||||
|
releasedAt: new Date(input.now),
|
||||||
|
expiresAt: new Date(input.now),
|
||||||
|
updatedAt: new Date(input.now),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
bindingPredicate(input.lease),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
||||||
|
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
||||||
|
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
||||||
|
isNull(logicalAgentConnectorLeases.releasedAt),
|
||||||
|
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
const row = updated[0];
|
||||||
|
if (row) {
|
||||||
|
const lease = toLease(row);
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, lease, 'release'));
|
||||||
|
return { ok: true, lease };
|
||||||
|
}
|
||||||
|
const current = await findRow(tx, input.lease);
|
||||||
|
const reason = classifyAuthorityFailure(
|
||||||
|
current ? toLease(current) : null,
|
||||||
|
input.lease,
|
||||||
|
input.now,
|
||||||
|
);
|
||||||
|
if (reason === 'lease_expired' && current) {
|
||||||
|
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||||
|
}
|
||||||
|
await insertAudit(
|
||||||
|
tx,
|
||||||
|
rejectionAudit(
|
||||||
|
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
||||||
|
current ? toLease(current) : null,
|
||||||
|
reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { ok: false, reason };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
unwrap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
||||||
|
const row = await findRow(this.db, binding);
|
||||||
|
return row ? toLease(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||||
|
await insertAudit(this.db, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap(result: MutationResult): ConnectorLease {
|
||||||
|
if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason));
|
||||||
|
return result.lease;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeErrorMessage(reason: ConnectorLeaseRejectReason): string {
|
||||||
|
return `Connector lease mutation denied: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingPredicate(binding: LogicalAgentBinding) {
|
||||||
|
return and(
|
||||||
|
eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId),
|
||||||
|
eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId),
|
||||||
|
eq(logicalAgentConnectorLeases.bindingId, binding.bindingId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findRow(
|
||||||
|
db: Pick<Db, 'select'>,
|
||||||
|
binding: LogicalAgentBinding,
|
||||||
|
): Promise<typeof logicalAgentConnectorLeases.$inferSelect | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(logicalAgentConnectorLeases)
|
||||||
|
.where(bindingPredicate(binding))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease {
|
||||||
|
return Object.freeze({
|
||||||
|
identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }),
|
||||||
|
bindingId: row.bindingId,
|
||||||
|
leaseId: row.leaseId,
|
||||||
|
connectorId: row.connectorId,
|
||||||
|
scopes: Object.freeze([...row.scopes]),
|
||||||
|
leaseEpoch: row.leaseEpoch.toString(10),
|
||||||
|
acquiredAt: row.acquiredAt.toISOString(),
|
||||||
|
heartbeatAt: row.heartbeatAt.toISOString(),
|
||||||
|
expiresAt: row.expiresAt.toISOString(),
|
||||||
|
...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyAuthorityFailure(
|
||||||
|
current: ConnectorLease | null,
|
||||||
|
claimed: ConnectorLease,
|
||||||
|
now: string,
|
||||||
|
): ConnectorLeaseRejectReason {
|
||||||
|
if (!current) return 'lease_missing';
|
||||||
|
if (current.releasedAt) return 'lease_released';
|
||||||
|
if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired';
|
||||||
|
if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch';
|
||||||
|
return 'connector_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifecycleAudit(
|
||||||
|
input: { readonly correlationId: string; readonly now: string },
|
||||||
|
lease: ConnectorLease,
|
||||||
|
event: Exclude<ConnectorLeaseAuditEvent['event'], 'reject'>,
|
||||||
|
): ConnectorLeaseAuditEvent {
|
||||||
|
return {
|
||||||
|
identity: lease.identity,
|
||||||
|
bindingId: lease.bindingId,
|
||||||
|
connectorId: lease.connectorId,
|
||||||
|
leaseId: lease.leaseId,
|
||||||
|
leaseEpoch: lease.leaseEpoch,
|
||||||
|
event,
|
||||||
|
outcome: 'succeeded',
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
occurredAt: input.now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectionAudit(
|
||||||
|
input: {
|
||||||
|
readonly identity: ConnectorLease['identity'];
|
||||||
|
readonly bindingId: string;
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly now: string;
|
||||||
|
},
|
||||||
|
current: ConnectorLease | null,
|
||||||
|
reason: ConnectorLeaseRejectReason,
|
||||||
|
): ConnectorLeaseAuditEvent {
|
||||||
|
return {
|
||||||
|
identity: input.identity,
|
||||||
|
bindingId: input.bindingId,
|
||||||
|
connectorId: input.connectorId,
|
||||||
|
event: 'reject',
|
||||||
|
outcome: 'denied',
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
occurredAt: input.now,
|
||||||
|
...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}),
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertAudit(db: Pick<Db, 'insert'>, event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||||
|
await db.insert(connectorLeaseAuditLog).values({
|
||||||
|
tenantId: event.identity.tenantId,
|
||||||
|
logicalAgentId: event.identity.logicalAgentId,
|
||||||
|
bindingId: event.bindingId,
|
||||||
|
connectorId: event.connectorId,
|
||||||
|
...(event.leaseId ? { leaseId: event.leaseId } : {}),
|
||||||
|
...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}),
|
||||||
|
event: event.event,
|
||||||
|
outcome: event.outcome,
|
||||||
|
...(event.reason ? { reason: event.reason } : {}),
|
||||||
|
correlationId: event.correlationId,
|
||||||
|
occurredAt: new Date(event.occurredAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ConnectorLeaseCoordinator, normalizeConnectorLease } from '@mosaicstack/agent';
|
||||||
|
import {
|
||||||
|
normalizeConnectorId,
|
||||||
|
normalizeConnectorScopes,
|
||||||
|
normalizeCorrelationId,
|
||||||
|
normalizeLogicalAgentIdentity,
|
||||||
|
normalizeLogicalBindingId,
|
||||||
|
type AcquireConnectorLeaseInput,
|
||||||
|
type ConnectorExecutionGrant,
|
||||||
|
type ConnectorLease,
|
||||||
|
type ConnectorLeaseAuditEvent,
|
||||||
|
type FencedConnectorAdapter,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||||
|
|
||||||
|
export const CONNECTOR_LEASE_POLICY = Symbol('CONNECTOR_LEASE_POLICY');
|
||||||
|
|
||||||
|
export type ConnectorLeasePolicyAction =
|
||||||
|
| 'lease.acquire'
|
||||||
|
| 'lease.takeover'
|
||||||
|
| 'lease.heartbeat'
|
||||||
|
| 'lease.release'
|
||||||
|
| 'lease.read'
|
||||||
|
| 'grant.issue';
|
||||||
|
|
||||||
|
export interface ConnectorLeaseRequestContext {
|
||||||
|
readonly actorScope: ActorTenantScope;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayConnectorLeaseRequest {
|
||||||
|
readonly logicalAgentId: string;
|
||||||
|
readonly bindingId: string;
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly scopes: readonly string[];
|
||||||
|
readonly ttlMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayConnectorLeaseTakeoverRequest extends GatewayConnectorLeaseRequest {
|
||||||
|
readonly expectedEpoch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayConnectorGrantRequest {
|
||||||
|
readonly lease: ConnectorLease;
|
||||||
|
readonly scopes: readonly string[];
|
||||||
|
readonly ttlMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorLeasePolicySubject {
|
||||||
|
readonly action: ConnectorLeasePolicyAction;
|
||||||
|
readonly actorId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly logicalAgentId: string;
|
||||||
|
readonly bindingId: string;
|
||||||
|
readonly connectorId: string;
|
||||||
|
readonly requestedScopes: readonly string[];
|
||||||
|
readonly requestedTtlMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectorLeasePolicy {
|
||||||
|
authorize(subject: ConnectorLeasePolicySubject): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M1 has no concrete cutover policy: unconfigured production use fails closed. */
|
||||||
|
@Injectable()
|
||||||
|
export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy {
|
||||||
|
async authorize(_subject: ConnectorLeasePolicySubject): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway-owned policy surface for durable connector authority and fenced effects. */
|
||||||
|
@Injectable()
|
||||||
|
export class ConnectorLeaseService {
|
||||||
|
private readonly coordinator: ConnectorLeaseCoordinator;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(ConnectorLeaseRepository) private readonly repository: ConnectorLeaseRepository,
|
||||||
|
@Inject(CONNECTOR_LEASE_POLICY) private readonly policy: ConnectorLeasePolicy,
|
||||||
|
) {
|
||||||
|
this.coordinator = new ConnectorLeaseCoordinator(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire(
|
||||||
|
request: GatewayConnectorLeaseRequest,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
): Promise<ConnectorLease> {
|
||||||
|
const command = this.command(request, context);
|
||||||
|
await this.assertPolicy('lease.acquire', command, context, command.scopes, command.ttlMs);
|
||||||
|
return this.coordinator.acquire({ ...command, correlationId: this.correlation(context) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async takeover(
|
||||||
|
request: GatewayConnectorLeaseTakeoverRequest,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
): Promise<ConnectorLease> {
|
||||||
|
const command = this.command(request, context);
|
||||||
|
await this.assertPolicy('lease.takeover', command, context, command.scopes, command.ttlMs);
|
||||||
|
return this.coordinator.takeover({
|
||||||
|
...command,
|
||||||
|
expectedEpoch: request.expectedEpoch,
|
||||||
|
correlationId: this.correlation(context),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async heartbeat(
|
||||||
|
lease: ConnectorLease,
|
||||||
|
ttlMs: number,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
): Promise<ConnectorLease> {
|
||||||
|
const normalizedLease = normalizeConnectorLease(lease);
|
||||||
|
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
|
||||||
|
await this.assertPolicy('lease.heartbeat', durableLease, context, durableLease.scopes, ttlMs);
|
||||||
|
return this.coordinator.heartbeat({
|
||||||
|
lease: durableLease,
|
||||||
|
ttlMs,
|
||||||
|
correlationId: this.correlation(context),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise<void> {
|
||||||
|
const normalizedLease = normalizeConnectorLease(lease);
|
||||||
|
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
|
||||||
|
await this.assertPolicy('lease.release', durableLease, context, durableLease.scopes, null);
|
||||||
|
await this.coordinator.release({
|
||||||
|
lease: durableLease,
|
||||||
|
correlationId: this.correlation(context),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async current(
|
||||||
|
logicalAgentId: string,
|
||||||
|
bindingId: string,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
): Promise<ConnectorLease | null> {
|
||||||
|
const binding = {
|
||||||
|
identity: normalizeLogicalAgentIdentity({
|
||||||
|
tenantId: context.actorScope.tenantId,
|
||||||
|
logicalAgentId,
|
||||||
|
}),
|
||||||
|
bindingId: normalizeLogicalBindingId(bindingId),
|
||||||
|
connectorId: 'gateway',
|
||||||
|
};
|
||||||
|
await this.assertPolicy('lease.read', binding, context, [], null);
|
||||||
|
return this.coordinator.current(binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
async issueGrant(
|
||||||
|
request: GatewayConnectorGrantRequest,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
): Promise<ConnectorExecutionGrant> {
|
||||||
|
const lease = normalizeConnectorLease(request.lease);
|
||||||
|
await this.assertTenant(lease, context);
|
||||||
|
const scopes = normalizeConnectorScopes(request.scopes);
|
||||||
|
await this.assertPolicy('grant.issue', lease, context, scopes, request.ttlMs);
|
||||||
|
return this.coordinator.issueGrant({
|
||||||
|
lease,
|
||||||
|
scopes,
|
||||||
|
ttlMs: request.ttlMs,
|
||||||
|
correlationId: this.correlation(context),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeGrant<TInput, TOutput>(
|
||||||
|
grant: ConnectorExecutionGrant,
|
||||||
|
requiredScope: string,
|
||||||
|
input: TInput,
|
||||||
|
adapter: FencedConnectorAdapter<TInput, TOutput>,
|
||||||
|
): Promise<TOutput> {
|
||||||
|
return this.coordinator.executeGrant(grant, requiredScope, input, adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private command(
|
||||||
|
request: GatewayConnectorLeaseRequest,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
): Omit<AcquireConnectorLeaseInput, 'correlationId'> {
|
||||||
|
return {
|
||||||
|
identity: normalizeLogicalAgentIdentity({
|
||||||
|
tenantId: context.actorScope.tenantId,
|
||||||
|
logicalAgentId: request.logicalAgentId,
|
||||||
|
}),
|
||||||
|
bindingId: normalizeLogicalBindingId(request.bindingId),
|
||||||
|
connectorId: normalizeConnectorId(request.connectorId),
|
||||||
|
scopes: normalizeConnectorScopes(request.scopes),
|
||||||
|
ttlMs: request.ttlMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertTenant(
|
||||||
|
lease: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
if (lease.identity.tenantId !== context.actorScope.tenantId) {
|
||||||
|
await this.recordPolicyDenial(
|
||||||
|
{
|
||||||
|
identity: {
|
||||||
|
tenantId: context.actorScope.tenantId,
|
||||||
|
logicalAgentId: 'untrusted',
|
||||||
|
},
|
||||||
|
bindingId: 'untrusted',
|
||||||
|
connectorId: 'untrusted',
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
throw new ForbiddenException('Connector authority tenant scope denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async durableLifecycleLease(
|
||||||
|
submittedLease: ConnectorLease,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
): Promise<ConnectorLease> {
|
||||||
|
await this.assertTenant(submittedLease, context);
|
||||||
|
const durableLease = await this.coordinator.current(submittedLease);
|
||||||
|
if (!durableLease || !hasSameLifecycleAuthority(submittedLease, durableLease)) {
|
||||||
|
await this.recordPolicyDenial(durableLease ?? submittedLease, context);
|
||||||
|
throw new ForbiddenException('Connector authority policy denied');
|
||||||
|
}
|
||||||
|
return durableLease;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPolicy(
|
||||||
|
action: ConnectorLeasePolicyAction,
|
||||||
|
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
requestedScopes: readonly string[],
|
||||||
|
requestedTtlMs: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const allowed = await this.policy.authorize({
|
||||||
|
action,
|
||||||
|
actorId: context.actorScope.userId,
|
||||||
|
tenantId: subject.identity.tenantId,
|
||||||
|
logicalAgentId: subject.identity.logicalAgentId,
|
||||||
|
bindingId: subject.bindingId,
|
||||||
|
connectorId: subject.connectorId,
|
||||||
|
requestedScopes: Object.freeze([...requestedScopes]),
|
||||||
|
requestedTtlMs,
|
||||||
|
});
|
||||||
|
if (!allowed) {
|
||||||
|
await this.recordPolicyDenial(subject, context);
|
||||||
|
throw new ForbiddenException('Connector authority policy denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordPolicyDenial(
|
||||||
|
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
||||||
|
context: ConnectorLeaseRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const event: ConnectorLeaseAuditEvent = {
|
||||||
|
identity: subject.identity,
|
||||||
|
bindingId: subject.bindingId,
|
||||||
|
connectorId: subject.connectorId,
|
||||||
|
event: 'reject',
|
||||||
|
outcome: 'denied',
|
||||||
|
reason: 'policy_denied',
|
||||||
|
correlationId: this.correlation(context),
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.repository.recordAudit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private correlation(context: ConnectorLeaseRequestContext): string {
|
||||||
|
return normalizeCorrelationId(context.correlationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSameLifecycleAuthority(
|
||||||
|
submittedLease: ConnectorLease,
|
||||||
|
durableLease: ConnectorLease,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
submittedLease.identity.tenantId === durableLease.identity.tenantId &&
|
||||||
|
submittedLease.identity.logicalAgentId === durableLease.identity.logicalAgentId &&
|
||||||
|
submittedLease.bindingId === durableLease.bindingId &&
|
||||||
|
submittedLease.leaseId === durableLease.leaseId &&
|
||||||
|
submittedLease.connectorId === durableLease.connectorId &&
|
||||||
|
submittedLease.leaseEpoch === durableLease.leaseEpoch &&
|
||||||
|
submittedLease.scopes.length === durableLease.scopes.length &&
|
||||||
|
submittedLease.scopes.every((scope: string, index: number): boolean => {
|
||||||
|
return scope === durableLease.scopes[index];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 [email protected] api_key=super-secret-canary',
|
||||||
|
});
|
||||||
|
await coordinator.checkpoint({
|
||||||
|
sessionId: IDENTITY.sessionId,
|
||||||
|
checkpointId: 'redacted-checkpoint',
|
||||||
|
cursor: 'bearer super-secret-canary',
|
||||||
|
summary: 'email [email protected]',
|
||||||
|
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('[email protected]');
|
||||||
|
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', '[email protected]', false, now(), now())
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
|
import {
|
||||||
|
createRuntimeAuditLogEntry,
|
||||||
|
type LogService,
|
||||||
|
type RuntimeAuditErrorCode,
|
||||||
|
} from '@mosaicstack/log';
|
||||||
import type {
|
import type {
|
||||||
AgentRuntimeProvider,
|
AgentRuntimeProvider,
|
||||||
RuntimeAttachHandle,
|
RuntimeAttachHandle,
|
||||||
@@ -12,8 +17,11 @@ import type {
|
|||||||
RuntimeSession,
|
RuntimeSession,
|
||||||
RuntimeSessionTree,
|
RuntimeSessionTree,
|
||||||
RuntimeStreamEvent,
|
RuntimeStreamEvent,
|
||||||
|
TransitionalCapabilityInventoryEntry,
|
||||||
|
TransitionalCapabilityInventoryProvider,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaicstack/types';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
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 AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
|
||||||
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
|
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
|
||||||
@@ -22,7 +30,8 @@ export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
|
|||||||
export type RuntimeProviderOperation =
|
export type RuntimeProviderOperation =
|
||||||
| RuntimeCapability
|
| RuntimeCapability
|
||||||
| 'runtime.capabilities'
|
| 'runtime.capabilities'
|
||||||
| 'runtime.health';
|
| 'runtime.health'
|
||||||
|
| 'runtime.transitional-capabilities';
|
||||||
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
||||||
|
|
||||||
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
||||||
@@ -42,6 +51,8 @@ export interface RuntimeAuditEvent {
|
|||||||
channelId: string;
|
channelId: string;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
errorCode?: RuntimeAuditErrorCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeAuditSink {
|
export interface RuntimeAuditSink {
|
||||||
@@ -56,13 +67,29 @@ export interface RuntimeTerminationAction {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
|
agentName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeApprovalVerifier {
|
export interface RuntimeApprovalVerifier {
|
||||||
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class RuntimeApprovalDeniedError extends Error {
|
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() {
|
constructor() {
|
||||||
super('Runtime termination approval denied');
|
super('Runtime termination approval denied');
|
||||||
}
|
}
|
||||||
@@ -87,8 +114,12 @@ export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
|||||||
export class RuntimeProviderAuditService implements RuntimeAuditSink {
|
export class RuntimeProviderAuditService implements RuntimeAuditSink {
|
||||||
private readonly logger = new Logger(RuntimeProviderAuditService.name);
|
private readonly logger = new Logger(RuntimeProviderAuditService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
|
||||||
|
|
||||||
async record(event: RuntimeAuditEvent): Promise<void> {
|
async record(event: RuntimeAuditEvent): Promise<void> {
|
||||||
this.logger.log(JSON.stringify(event));
|
const entry = createRuntimeAuditLogEntry(event);
|
||||||
|
await this.logService.logs.ingest(entry);
|
||||||
|
this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +163,25 @@ export class RuntimeProviderService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
async listSessions(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
context: RuntimeProviderRequestContext,
|
context: RuntimeProviderRequestContext,
|
||||||
@@ -249,6 +299,7 @@ export class RuntimeProviderService {
|
|||||||
tenantId: scope.tenantId,
|
tenantId: scope.tenantId,
|
||||||
channelId: scope.channelId,
|
channelId: scope.channelId,
|
||||||
correlationId: scope.correlationId,
|
correlationId: scope.correlationId,
|
||||||
|
agentName: configuredAgentName(),
|
||||||
});
|
});
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
throw new RuntimeApprovalDeniedError();
|
throw new RuntimeApprovalDeniedError();
|
||||||
@@ -267,6 +318,7 @@ export class RuntimeProviderService {
|
|||||||
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
|
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const scope = this.deriveScope(context);
|
const scope = this.deriveScope(context);
|
||||||
|
const startedAt = Date.now();
|
||||||
await this.record(providerId, operation, 'requested', scope, resourceId);
|
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||||
let invocationStarted = false;
|
let invocationStarted = false;
|
||||||
try {
|
try {
|
||||||
@@ -276,13 +328,22 @@ export class RuntimeProviderService {
|
|||||||
}
|
}
|
||||||
invocationStarted = true;
|
invocationStarted = true;
|
||||||
const result = await invoke(provider, scope);
|
const result = await invoke(provider, scope);
|
||||||
await this.recordCompletion(providerId, operation, scope, resourceId);
|
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
|
||||||
return result;
|
return result;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) {
|
const durationMs = Date.now() - startedAt;
|
||||||
await this.recordFailure(providerId, operation, scope, resourceId);
|
if (invocationStarted && !this.isAuthorizationDenied(error)) {
|
||||||
|
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
||||||
} else {
|
} else {
|
||||||
await this.record(providerId, operation, 'denied', scope, resourceId);
|
await this.record(
|
||||||
|
providerId,
|
||||||
|
operation,
|
||||||
|
'denied',
|
||||||
|
scope,
|
||||||
|
resourceId,
|
||||||
|
durationMs,
|
||||||
|
'policy_denied',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -300,6 +361,7 @@ export class RuntimeProviderService {
|
|||||||
) => AsyncIterable<RuntimeStreamEvent>,
|
) => AsyncIterable<RuntimeStreamEvent>,
|
||||||
): AsyncIterable<RuntimeStreamEvent> {
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
const scope = this.deriveScope(context);
|
const scope = this.deriveScope(context);
|
||||||
|
const startedAt = Date.now();
|
||||||
await this.record(providerId, operation, 'requested', scope, resourceId);
|
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||||
let invocationStarted = false;
|
let invocationStarted = false;
|
||||||
try {
|
try {
|
||||||
@@ -309,17 +371,37 @@ export class RuntimeProviderService {
|
|||||||
for await (const event of invoke(provider, scope)) {
|
for await (const event of invoke(provider, scope)) {
|
||||||
yield event;
|
yield event;
|
||||||
}
|
}
|
||||||
await this.recordCompletion(providerId, operation, scope, resourceId);
|
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
if (invocationStarted) {
|
if (invocationStarted) {
|
||||||
await this.recordFailure(providerId, operation, scope, resourceId);
|
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
||||||
} else {
|
} else {
|
||||||
await this.record(providerId, operation, 'denied', scope, resourceId);
|
await this.record(
|
||||||
|
providerId,
|
||||||
|
operation,
|
||||||
|
'denied',
|
||||||
|
scope,
|
||||||
|
resourceId,
|
||||||
|
durationMs,
|
||||||
|
'policy_denied',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
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 {
|
private provider(providerId: string): AgentRuntimeProvider {
|
||||||
try {
|
try {
|
||||||
return this.registry.require(providerId);
|
return this.registry.require(providerId);
|
||||||
@@ -358,9 +440,18 @@ export class RuntimeProviderService {
|
|||||||
operation: RuntimeProviderOperation,
|
operation: RuntimeProviderOperation,
|
||||||
scope: RuntimeScope,
|
scope: RuntimeScope,
|
||||||
resourceId: string | undefined,
|
resourceId: string | undefined,
|
||||||
|
durationMs: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.record(providerId, operation, 'failed', scope, resourceId);
|
await this.record(
|
||||||
|
providerId,
|
||||||
|
operation,
|
||||||
|
'failed',
|
||||||
|
scope,
|
||||||
|
resourceId,
|
||||||
|
durationMs,
|
||||||
|
'provider_error',
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||||
@@ -373,9 +464,10 @@ export class RuntimeProviderService {
|
|||||||
operation: RuntimeProviderOperation,
|
operation: RuntimeProviderOperation,
|
||||||
scope: RuntimeScope,
|
scope: RuntimeScope,
|
||||||
resourceId: string | undefined,
|
resourceId: string | undefined,
|
||||||
|
durationMs: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.record(providerId, operation, 'succeeded', scope, resourceId);
|
await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs);
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||||
@@ -389,6 +481,8 @@ export class RuntimeProviderService {
|
|||||||
outcome: RuntimeProviderAuditOutcome,
|
outcome: RuntimeProviderAuditOutcome,
|
||||||
scope: RuntimeScope,
|
scope: RuntimeScope,
|
||||||
resourceId: string | undefined,
|
resourceId: string | undefined,
|
||||||
|
durationMs?: number,
|
||||||
|
errorCode?: RuntimeAuditErrorCode,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
providerId,
|
providerId,
|
||||||
@@ -399,6 +493,8 @@ export class RuntimeProviderService {
|
|||||||
channelId: scope.channelId,
|
channelId: scope.channelId,
|
||||||
correlationId: scope.correlationId,
|
correlationId: scope.correlationId,
|
||||||
...(resourceId ? { resourceId } : {}),
|
...(resourceId ? { resourceId } : {}),
|
||||||
|
...(durationMs !== undefined ? { durationMs } : {}),
|
||||||
|
...(errorCode ? { errorCode } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
||||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
export class ChatRequestDto {
|
export class ChatRequestDto {
|
||||||
@@ -32,4 +33,7 @@ export class ChatSocketMessageDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
|
||||||
|
/** Validated channel attachment references; binary content is not embedded. */
|
||||||
|
attachments?: readonly ChannelAttachmentDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { ChatGateway } from './chat.gateway.js';
|
|||||||
const CONVERSATION_ID = 'conversation-1';
|
const CONVERSATION_ID = 'conversation-1';
|
||||||
const CANARY = 'sk_canary12345678';
|
const CANARY = 'sk_canary12345678';
|
||||||
|
|
||||||
|
function clientConversationKey(clientId: string, conversationId: string): string {
|
||||||
|
return `${clientId}\u0000${conversationId}`;
|
||||||
|
}
|
||||||
|
|
||||||
type GatewayInternals = {
|
type GatewayInternals = {
|
||||||
clientSessions: Map<string, unknown>;
|
clientSessions: Map<string, unknown>;
|
||||||
relayEvent(client: unknown, conversationId: string, event: unknown): void;
|
relayEvent(client: unknown, conversationId: string, event: unknown): void;
|
||||||
@@ -40,6 +44,7 @@ describe('ChatGateway redaction boundary', (): void => {
|
|||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
};
|
};
|
||||||
const session = {
|
const session = {
|
||||||
|
clientId: client.id,
|
||||||
conversationId: CONVERSATION_ID,
|
conversationId: CONVERSATION_ID,
|
||||||
cleanup: vi.fn(),
|
cleanup: vi.fn(),
|
||||||
assistantText: '',
|
assistantText: '',
|
||||||
@@ -47,7 +52,7 @@ describe('ChatGateway redaction boundary', (): void => {
|
|||||||
pendingToolCalls: new Map(),
|
pendingToolCalls: new Map(),
|
||||||
scope: { userId: 'user-1', tenantId: 'tenant-1' },
|
scope: { userId: 'user-1', tenantId: 'tenant-1' },
|
||||||
};
|
};
|
||||||
gateway.clientSessions.set(client.id, session);
|
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session);
|
||||||
|
|
||||||
gateway.relayEvent(client, CONVERSATION_ID, {
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
type: 'message_update',
|
type: 'message_update',
|
||||||
@@ -139,6 +144,51 @@ describe('ChatGateway redaction boundary', (): void => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('isolates concurrent conversation streams sharing one Discord socket', (): void => {
|
||||||
|
const { gateway } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'discord-client',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const firstConversation = 'Nova:discord:thread-1';
|
||||||
|
const secondConversation = 'Nova:discord:thread-2';
|
||||||
|
const createSession = (conversationId: string) => ({
|
||||||
|
clientId: client.id,
|
||||||
|
conversationId,
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
assistantText: '',
|
||||||
|
toolCalls: [],
|
||||||
|
pendingToolCalls: new Map(),
|
||||||
|
scope: { userId: 'user-1', tenantId: 'tenant-1' },
|
||||||
|
});
|
||||||
|
const firstSession = createSession(firstConversation);
|
||||||
|
const secondSession = createSession(secondConversation);
|
||||||
|
gateway.clientSessions.set(clientConversationKey(client.id, firstConversation), firstSession);
|
||||||
|
gateway.clientSessions.set(clientConversationKey(client.id, secondConversation), secondSession);
|
||||||
|
|
||||||
|
gateway.relayEvent(client, firstConversation, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: 'first response ' },
|
||||||
|
});
|
||||||
|
gateway.relayEvent(client, secondConversation, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: 'second response ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstSession.assistantText).toBe('first response ');
|
||||||
|
expect(secondSession.assistantText).toBe('second response ');
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: firstConversation,
|
||||||
|
text: 'first response ',
|
||||||
|
});
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: secondConversation,
|
||||||
|
text: 'second response ',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('persists only redacted assistant content with classifications', (): void => {
|
it('persists only redacted assistant content with classifications', (): void => {
|
||||||
const { gateway, brain } = buildGateway();
|
const { gateway, brain } = buildGateway();
|
||||||
const client = {
|
const client = {
|
||||||
@@ -147,7 +197,8 @@ describe('ChatGateway redaction boundary', (): void => {
|
|||||||
data: { user: { id: 'user-1' } },
|
data: { user: { id: 'user-1' } },
|
||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
};
|
};
|
||||||
gateway.clientSessions.set(client.id, {
|
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), {
|
||||||
|
clientId: client.id,
|
||||||
conversationId: CONVERSATION_ID,
|
conversationId: CONVERSATION_ID,
|
||||||
cleanup: vi.fn(),
|
cleanup: vi.fn(),
|
||||||
assistantText: CANARY,
|
assistantText: 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,
|
||||||
@@ -13,6 +14,10 @@ import { Server, Socket } from 'socket.io';
|
|||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
import {
|
import {
|
||||||
verifyDiscordIngressEnvelope,
|
verifyDiscordIngressEnvelope,
|
||||||
|
parseDiscordInteractionBindings,
|
||||||
|
resolveDiscordInteractionActorId,
|
||||||
|
resolveDiscordInteractionBinding,
|
||||||
|
type DiscordAttachment,
|
||||||
type DiscordIngressEnvelope,
|
type DiscordIngressEnvelope,
|
||||||
type DiscordIngressPayload,
|
type DiscordIngressPayload,
|
||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
@@ -26,8 +31,15 @@ import type {
|
|||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
RoutingDecisionInfo,
|
RoutingDecisionInfo,
|
||||||
AbortPayload,
|
AbortPayload,
|
||||||
|
ChannelAttachmentDto,
|
||||||
} 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 {
|
import {
|
||||||
scopeFromUser,
|
scopeFromUser,
|
||||||
@@ -37,6 +49,7 @@ import {
|
|||||||
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';
|
||||||
@@ -45,6 +58,7 @@ 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 {
|
||||||
|
clientId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
/** Accumulated assistant response text for the current turn. */
|
/** Accumulated assistant response text for the current turn. */
|
||||||
@@ -65,6 +79,68 @@ interface ClientSession {
|
|||||||
*/
|
*/
|
||||||
const modelOverrides = new Map<string, string>();
|
const modelOverrides = new Map<string, string>();
|
||||||
const MAX_REDACTION_BUFFER_LENGTH = 8_192;
|
const MAX_REDACTION_BUFFER_LENGTH = 8_192;
|
||||||
|
const MAX_CHANNEL_ATTACHMENTS = 10;
|
||||||
|
const MAX_ATTACHMENT_METADATA_BYTES = 16_384;
|
||||||
|
const MAX_ATTACHMENT_ID_LENGTH = 128;
|
||||||
|
const MAX_ATTACHMENT_NAME_LENGTH = 255;
|
||||||
|
const MAX_ATTACHMENT_URL_LENGTH = 2_048;
|
||||||
|
const MAX_ATTACHMENT_MIME_LENGTH = 255;
|
||||||
|
|
||||||
|
function isSafeAttachmentUrl(value: string): boolean {
|
||||||
|
if (value.length === 0 || value.length > MAX_ATTACHMENT_URL_LENGTH) return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (
|
||||||
|
url.protocol === 'https:' &&
|
||||||
|
!url.username &&
|
||||||
|
!url.password &&
|
||||||
|
!url.hash &&
|
||||||
|
url.search.length === 0
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidAttachmentBounds(value: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
value.id.length > 0 &&
|
||||||
|
value.id.length <= MAX_ATTACHMENT_ID_LENGTH &&
|
||||||
|
value.name.length > 0 &&
|
||||||
|
value.name.length <= MAX_ATTACHMENT_NAME_LENGTH &&
|
||||||
|
isSafeAttachmentUrl(value.url) &&
|
||||||
|
(value.sizeBytes === undefined || (Number.isFinite(value.sizeBytes) && value.sizeBytes >= 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDiscordAttachment(value: unknown): value is DiscordAttachment {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const attachment = value as Partial<DiscordAttachment>;
|
||||||
|
return (
|
||||||
|
typeof attachment.id === 'string' &&
|
||||||
|
typeof attachment.name === 'string' &&
|
||||||
|
typeof attachment.url === 'string' &&
|
||||||
|
(attachment.contentType === null ||
|
||||||
|
(typeof attachment.contentType === 'string' &&
|
||||||
|
attachment.contentType.length <= MAX_ATTACHMENT_MIME_LENGTH)) &&
|
||||||
|
(attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') &&
|
||||||
|
hasValidAttachmentBounds(attachment as DiscordAttachment)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidAttachmentArray(value: unknown, guard: (attachment: unknown) => boolean): boolean {
|
||||||
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length <= MAX_CHANNEL_ATTACHMENTS &&
|
||||||
|
JSON.stringify(value).length <= MAX_ATTACHMENT_METADATA_BYTES &&
|
||||||
|
value.every(guard)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
|
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
@@ -77,23 +153,49 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const payload = envelope.payload as Record<string, unknown>;
|
const payload = envelope.payload as Record<string, unknown>;
|
||||||
return [
|
return (
|
||||||
payload['correlationId'],
|
[
|
||||||
payload['messageId'],
|
payload['correlationId'],
|
||||||
payload['guildId'],
|
payload['messageId'],
|
||||||
payload['channelId'],
|
payload['guildId'],
|
||||||
payload['userId'],
|
payload['channelId'],
|
||||||
payload['conversationId'],
|
payload['userId'],
|
||||||
payload['content'],
|
payload['conversationId'],
|
||||||
].every((field: unknown): boolean => typeof field === 'string');
|
payload['content'],
|
||||||
|
].every((field: unknown): boolean => typeof field === 'string') &&
|
||||||
|
(payload['threadId'] === undefined || typeof payload['threadId'] === 'string') &&
|
||||||
|
(payload['attachments'] === undefined ||
|
||||||
|
hasValidAttachmentArray(payload['attachments'], isDiscordAttachment))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChannelAttachment(value: unknown): value is ChannelAttachmentDto {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const attachment = value as Partial<ChannelAttachmentDto>;
|
||||||
|
return (
|
||||||
|
typeof attachment.id === 'string' &&
|
||||||
|
typeof attachment.name === 'string' &&
|
||||||
|
typeof attachment.url === 'string' &&
|
||||||
|
(attachment.mimeType === null ||
|
||||||
|
(typeof attachment.mimeType === 'string' &&
|
||||||
|
attachment.mimeType.length <= MAX_ATTACHMENT_MIME_LENGTH)) &&
|
||||||
|
(attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') &&
|
||||||
|
hasValidAttachmentBounds(attachment as ChannelAttachmentDto)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
|
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
const payload = value as { content?: unknown; conversationId?: unknown };
|
const payload = value as {
|
||||||
|
content?: unknown;
|
||||||
|
conversationId?: unknown;
|
||||||
|
attachments?: unknown;
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
typeof payload.content === 'string' &&
|
typeof payload.content === 'string' &&
|
||||||
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
|
(payload.conversationId === undefined || typeof payload.conversationId === 'string') &&
|
||||||
|
(payload.attachments === undefined ||
|
||||||
|
hasValidAttachmentArray(payload.attachments, isChannelAttachment))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +224,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 {
|
||||||
@@ -151,20 +265,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
handleDisconnect(client: Socket): void {
|
handleDisconnect(client: Socket): void {
|
||||||
this.logger.log(`Client disconnected: ${client.id}`);
|
this.logger.log(`Client disconnected: ${client.id}`);
|
||||||
const session = this.clientSessions.get(client.id);
|
for (const [key, session] of this.clientSessions) {
|
||||||
if (session) {
|
if (session.clientId !== client.id) continue;
|
||||||
session.cleanup();
|
session.cleanup();
|
||||||
this.agentService.removeChannel(
|
this.agentService.removeChannel(
|
||||||
session.conversationId,
|
session.conversationId,
|
||||||
`websocket:${client.id}`,
|
`websocket:${client.id}`,
|
||||||
session.scope,
|
session.scope,
|
||||||
);
|
);
|
||||||
this.clientSessions.delete(client.id);
|
this.clientSessions.delete(key);
|
||||||
|
this.textEgressBuffers.delete(key);
|
||||||
|
this.thinkingEgressBuffers.delete(key);
|
||||||
|
this.overflowedEgress.delete(`${key}:agent:text`);
|
||||||
|
this.overflowedEgress.delete(`${key}:agent:thinking`);
|
||||||
}
|
}
|
||||||
this.textEgressBuffers.delete(client.id);
|
}
|
||||||
this.thinkingEgressBuffers.delete(client.id);
|
|
||||||
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
private clientConversationKey(client: Pick<Socket, 'id'>, conversationId: string): string {
|
||||||
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
|
return `${client.id}\u0000${conversationId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClientScope(client: Socket): ActorTenantScope | null {
|
private getClientScope(client: Socket): ActorTenantScope | null {
|
||||||
@@ -195,7 +313,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
discordIngress = this.resolveDiscordIngress(client, rawData);
|
discordIngress = this.resolveDiscordIngress(client, rawData);
|
||||||
if (!discordIngress) return;
|
if (!discordIngress) return;
|
||||||
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
|
data = {
|
||||||
|
conversationId: discordIngress.conversationId,
|
||||||
|
content: discordIngress.content,
|
||||||
|
...(discordIngress.attachments
|
||||||
|
? {
|
||||||
|
attachments: discordIngress.attachments.map(
|
||||||
|
(attachment): ChannelAttachmentDto => ({
|
||||||
|
id: attachment.id,
|
||||||
|
name: attachment.name,
|
||||||
|
url: attachment.url,
|
||||||
|
mimeType: attachment.contentType,
|
||||||
|
...(attachment.sizeBytes !== undefined
|
||||||
|
? { sizeBytes: attachment.sizeBytes }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
if (!isChatSocketMessage(rawData)) {
|
if (!isChatSocketMessage(rawData)) {
|
||||||
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
|
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
|
||||||
@@ -204,6 +340,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
data = rawData;
|
data = rawData;
|
||||||
}
|
}
|
||||||
const conversationId = data.conversationId ?? uuid();
|
const conversationId = data.conversationId ?? uuid();
|
||||||
|
const clientConversationKey = this.clientConversationKey(client, conversationId);
|
||||||
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
||||||
if (discordIngress && !discordServiceUserId) {
|
if (discordIngress && !discordServiceUserId) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -258,7 +395,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Using /model override "${modelOverride}" for conversation=${conversationId}`,
|
`Using /model override "${modelOverride}" for conversation=${conversationId}`,
|
||||||
);
|
);
|
||||||
} else if (!resolvedProvider && !resolvedModelId) {
|
} else if (!resolvedProvider && !resolvedModelId && !discordIngress) {
|
||||||
// No explicit provider/model from client — use routing engine (M4-012)
|
// No explicit provider/model from client — use routing engine (M4-012)
|
||||||
try {
|
try {
|
||||||
const routingDecision = await this.routingEngine.resolve(data.content, userId);
|
const routingDecision = await this.routingEngine.resolve(data.content, userId);
|
||||||
@@ -281,12 +418,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resolvedAgentConfigId = data.agentId;
|
||||||
|
if (discordIngress) {
|
||||||
|
const binding = this.discordBindingFor(discordIngress, 'send');
|
||||||
|
const agentConfig = binding
|
||||||
|
? await this.brain.agents.findById(binding.agentConfigId)
|
||||||
|
: undefined;
|
||||||
|
if (!binding || !agentConfig || agentConfig.name !== binding.instanceId) {
|
||||||
|
throw new Error('Configured Discord logical agent is not provisioned');
|
||||||
|
}
|
||||||
|
resolvedAgentConfigId = agentConfig.id;
|
||||||
|
}
|
||||||
|
|
||||||
// M5-004: Use existingSessionId as sessionId when available (session reuse)
|
// M5-004: Use existingSessionId as sessionId when available (session reuse)
|
||||||
const sessionIdToCreate = existingSessionId ?? conversationId;
|
const sessionIdToCreate = existingSessionId ?? conversationId;
|
||||||
agentSession = await this.agentService.createSession(sessionIdToCreate, {
|
agentSession = await this.agentService.createSession(sessionIdToCreate, {
|
||||||
provider: resolvedProvider,
|
provider: resolvedProvider,
|
||||||
modelId: resolvedModelId,
|
modelId: resolvedModelId,
|
||||||
agentConfigId: data.agentId,
|
agentConfigId: resolvedAgentConfigId,
|
||||||
userId,
|
userId,
|
||||||
tenantId: scope.tenantId,
|
tenantId: scope.tenantId,
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
@@ -337,6 +486,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
discordUserId: discordIngress?.userId,
|
discordUserId: discordIngress?.userId,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(data.attachments && data.attachments.length > 0
|
||||||
|
? {
|
||||||
|
channelAttachments: data.attachments.map(
|
||||||
|
(attachment): ChannelAttachmentDto => ({
|
||||||
|
...attachment,
|
||||||
|
name: redactSensitiveContent(attachment.name).content,
|
||||||
|
url: redactSensitiveContent(attachment.url).content,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
classifications: redactSensitiveContent(data.content).classifications,
|
classifications: redactSensitiveContent(data.content).classifications,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -351,7 +511,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always clean up previous listener to prevent leak
|
// Always clean up previous listener to prevent leak
|
||||||
const existing = this.clientSessions.get(client.id);
|
const existing = this.clientSessions.get(clientConversationKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.cleanup();
|
existing.cleanup();
|
||||||
}
|
}
|
||||||
@@ -366,10 +526,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 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(clientConversationKey);
|
||||||
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
|
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
|
||||||
|
|
||||||
this.clientSessions.set(client.id, {
|
this.clientSessions.set(clientConversationKey, {
|
||||||
|
clientId: client.id,
|
||||||
conversationId,
|
conversationId,
|
||||||
cleanup,
|
cleanup,
|
||||||
assistantText: '',
|
assistantText: '',
|
||||||
@@ -415,7 +576,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
// Dispatch to agent
|
// Dispatch to agent
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, data.content, scope);
|
await this.agentService.prompt(conversationId, data.content, scope, data.attachments);
|
||||||
} 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}`,
|
||||||
@@ -622,9 +783,9 @@ 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 session of this.clientSessions.values()) {
|
||||||
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
|
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
|
||||||
const socket = this.server.sockets.sockets.get(clientId);
|
const socket = this.server.sockets.sockets.get(session.clientId);
|
||||||
if (socket?.connected) {
|
if (socket?.connected) {
|
||||||
socket.emit('session:info', payload);
|
socket.emit('session:info', payload);
|
||||||
}
|
}
|
||||||
@@ -637,9 +798,173 @@ 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 = this.discordBindingFor(ingress, 'approve');
|
||||||
|
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||||
|
const agentName = binding?.instanceId;
|
||||||
|
if (!actorId || !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 = this.discordBindingFor(ingress, '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(
|
private resolveDiscordIngress(
|
||||||
client: Socket,
|
client: Socket,
|
||||||
envelope: DiscordIngressEnvelope,
|
envelope: DiscordIngressEnvelope,
|
||||||
|
operation: 'send' | 'approve' | 'stop' = 'send',
|
||||||
): DiscordIngressPayload | null {
|
): DiscordIngressPayload | null {
|
||||||
const payload = verifyDiscordIngressEnvelope(
|
const payload = verifyDiscordIngressEnvelope(
|
||||||
envelope,
|
envelope,
|
||||||
@@ -654,6 +979,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const binding = this.discordBindingFor(payload, operation);
|
||||||
|
if (!binding) {
|
||||||
|
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expectedConversationId = `${binding.instanceId}:discord:${payload.threadId ?? payload.channelId}`;
|
||||||
|
if (payload.conversationId !== expectedConversationId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord ingress for a different logical agent 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)) {
|
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
||||||
@@ -663,6 +1007,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private discordBindingFor(
|
||||||
|
payload: DiscordIngressPayload,
|
||||||
|
operation: 'send' | 'approve' | 'stop',
|
||||||
|
) {
|
||||||
|
return resolveDiscordInteractionBinding(
|
||||||
|
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||||
|
payload.guildId,
|
||||||
|
payload.channelId,
|
||||||
|
payload.userId,
|
||||||
|
operation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private readDiscordAllowlist(name: string): string[] {
|
private readDiscordAllowlist(name: string): string[] {
|
||||||
return (process.env[name] ?? '')
|
return (process.env[name] ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -741,11 +1098,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const messages = await this.brain.conversations.findMessages(conversationId, userId);
|
const messages = await this.brain.conversations.findMessages(conversationId, userId);
|
||||||
if (messages.length === 0) return [];
|
if (messages.length === 0) return [];
|
||||||
|
|
||||||
return messages.map((msg) => ({
|
return messages.map((msg) => {
|
||||||
role: msg.role as 'user' | 'assistant' | 'system',
|
const attachments = this.persistedChannelAttachments(msg.metadata);
|
||||||
content: msg.content,
|
return {
|
||||||
createdAt: msg.createdAt,
|
role: msg.role as 'user' | 'assistant' | 'system',
|
||||||
}));
|
content: msg.content,
|
||||||
|
createdAt: msg.createdAt,
|
||||||
|
...(attachments ? { attachments } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to load conversation history for conversation=${conversationId}`,
|
`Failed to load conversation history for conversation=${conversationId}`,
|
||||||
@@ -755,6 +1116,14 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private persistedChannelAttachments(metadata: unknown): readonly ChannelAttachmentDto[] | null {
|
||||||
|
if (typeof metadata !== 'object' || metadata === null) return null;
|
||||||
|
const attachments = (metadata as { channelAttachments?: unknown }).channelAttachments;
|
||||||
|
return hasValidAttachmentArray(attachments, isChannelAttachment)
|
||||||
|
? (attachments as readonly ChannelAttachmentDto[])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private appendAndFlushRedactedEgress(
|
private appendAndFlushRedactedEgress(
|
||||||
client: Socket,
|
client: Socket,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
@@ -762,18 +1131,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
buffers: Map<string, string>,
|
buffers: Map<string, string>,
|
||||||
delta: string,
|
delta: string,
|
||||||
): void {
|
): void {
|
||||||
const key = this.egressKey(client, eventName);
|
const sessionKey = this.clientConversationKey(client, conversationId);
|
||||||
|
const key = this.egressKey(client, conversationId, eventName);
|
||||||
if (this.overflowedEgress.has(key)) return;
|
if (this.overflowedEgress.has(key)) return;
|
||||||
|
|
||||||
const buffered = `${buffers.get(client.id) ?? ''}${delta}`;
|
const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`;
|
||||||
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
|
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
|
||||||
buffers.delete(client.id);
|
buffers.delete(sessionKey);
|
||||||
this.overflowedEgress.add(key);
|
this.overflowedEgress.add(key);
|
||||||
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
|
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
buffers.set(client.id, buffered);
|
buffers.set(sessionKey, buffered);
|
||||||
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
|
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,21 +1159,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
buffers: Map<string, string>,
|
buffers: Map<string, string>,
|
||||||
final: boolean,
|
final: boolean,
|
||||||
): void {
|
): void {
|
||||||
const key = this.egressKey(client, eventName);
|
const sessionKey = this.clientConversationKey(client, conversationId);
|
||||||
|
const key = this.egressKey(client, conversationId, eventName);
|
||||||
if (this.overflowedEgress.has(key)) {
|
if (this.overflowedEgress.has(key)) {
|
||||||
if (final) this.overflowedEgress.delete(key);
|
if (final) this.overflowedEgress.delete(key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffered = buffers.get(client.id) ?? '';
|
const buffered = buffers.get(sessionKey) ?? '';
|
||||||
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
|
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
|
||||||
const released = buffered.slice(0, releaseLength);
|
const released = buffered.slice(0, releaseLength);
|
||||||
const pending = buffered.slice(releaseLength);
|
const pending = buffered.slice(releaseLength);
|
||||||
|
|
||||||
if (pending) {
|
if (pending) {
|
||||||
buffers.set(client.id, pending);
|
buffers.set(sessionKey, pending);
|
||||||
} else {
|
} else {
|
||||||
buffers.delete(client.id);
|
buffers.delete(sessionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (released) {
|
if (released) {
|
||||||
@@ -872,8 +1243,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
return retainedFrom;
|
return retainedFrom;
|
||||||
}
|
}
|
||||||
|
|
||||||
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string {
|
private egressKey(
|
||||||
return `${client.id}:${eventName}`;
|
client: Socket,
|
||||||
|
conversationId: string,
|
||||||
|
eventName: 'agent:text' | 'agent:thinking',
|
||||||
|
): string {
|
||||||
|
return `${this.clientConversationKey(client, conversationId)}:${eventName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||||
@@ -884,26 +1259,27 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionKey = this.clientConversationKey(client, conversationId);
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'agent_start': {
|
case 'agent_start': {
|
||||||
// Reset accumulation buffers for the new turn
|
// Reset accumulation buffers for the new turn
|
||||||
const cs = this.clientSessions.get(client.id);
|
const cs = this.clientSessions.get(sessionKey);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.assistantText = '';
|
cs.assistantText = '';
|
||||||
cs.toolCalls = [];
|
cs.toolCalls = [];
|
||||||
cs.pendingToolCalls.clear();
|
cs.pendingToolCalls.clear();
|
||||||
}
|
}
|
||||||
this.textEgressBuffers.set(client.id, '');
|
this.textEgressBuffers.set(sessionKey, '');
|
||||||
this.thinkingEgressBuffers.set(client.id, '');
|
this.thinkingEgressBuffers.set(sessionKey, '');
|
||||||
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text'));
|
||||||
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
|
this.overflowedEgress.delete(this.egressKey(client, conversationId, '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 activeClientSession = this.clientSessions.get(client.id);
|
const activeClientSession = this.clientSessions.get(sessionKey);
|
||||||
const agentSession = activeClientSession
|
const agentSession = activeClientSession
|
||||||
? this.agentService.getSession(conversationId, activeClientSession.scope)
|
? this.agentService.getSession(conversationId, activeClientSession.scope)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -956,7 +1332,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist the assistant message with metadata
|
// Persist the assistant message with metadata
|
||||||
const cs = this.clientSessions.get(client.id);
|
const cs = this.clientSessions.get(sessionKey);
|
||||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||||
if (cs && userId && cs.assistantText.trim().length > 0) {
|
if (cs && userId && cs.assistantText.trim().length > 0) {
|
||||||
const metadata: Record<string, unknown> = {
|
const metadata: Record<string, unknown> = {
|
||||||
@@ -1008,7 +1384,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const assistantEvent = event.assistantMessageEvent;
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
if (assistantEvent.type === 'text_delta') {
|
if (assistantEvent.type === 'text_delta') {
|
||||||
// Keep raw stream material in memory only; persist and emit only redacted text.
|
// 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(sessionKey);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.assistantText += assistantEvent.delta;
|
cs.assistantText += assistantEvent.delta;
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1409,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
case 'tool_execution_start': {
|
case 'tool_execution_start': {
|
||||||
// Track pending tool call for later recording
|
// Track pending tool call for later recording
|
||||||
const cs = this.clientSessions.get(client.id);
|
const cs = this.clientSessions.get(sessionKey);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.pendingToolCalls.set(event.toolCallId, {
|
cs.pendingToolCalls.set(event.toolCallId, {
|
||||||
toolName: event.toolName,
|
toolName: event.toolName,
|
||||||
@@ -1050,7 +1426,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
case 'tool_execution_end': {
|
case 'tool_execution_end': {
|
||||||
// Finalise tool call record
|
// Finalise tool call record
|
||||||
const cs = this.clientSessions.get(client.id);
|
const cs = this.clientSessions.get(sessionKey);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
const pending = cs.pendingToolCalls.get(event.toolCallId);
|
const pending = cs.pendingToolCalls.get(event.toolCallId);
|
||||||
cs.toolCalls.push({
|
cs.toolCalls.push({
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ const adminCommand: CommandDef = {
|
|||||||
};
|
};
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||||
|
|
||||||
function createService(role: string): CommandAuthorizationService {
|
function createService(
|
||||||
const entries = new Map<string, string>();
|
role: string,
|
||||||
|
entries: Map<string, string> = new Map<string, string>(),
|
||||||
|
): CommandAuthorizationService {
|
||||||
const db = {
|
const db = {
|
||||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||||
};
|
};
|
||||||
@@ -58,4 +60,57 @@ describe('CommandAuthorizationService', () => {
|
|||||||
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
||||||
).toBe(false);
|
).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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ export interface CommandApproval {
|
|||||||
expiresAt: 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 {
|
export interface CommandAuthorizationResult {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -75,6 +93,57 @@ export class CommandAuthorizationService {
|
|||||||
return approval;
|
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> {
|
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
||||||
const [user] = await this.db
|
const [user] = await this.db
|
||||||
.select({ role: usersTable.role })
|
.select({ role: usersTable.role })
|
||||||
@@ -98,12 +167,17 @@ export class CommandAuthorizationService {
|
|||||||
const key = this.key(approvalId);
|
const key = this.key(approvalId);
|
||||||
const encoded = await this.redis.get(key);
|
const encoded = await this.redis.get(key);
|
||||||
if (!encoded) return false;
|
if (!encoded) return false;
|
||||||
const parsed: unknown = JSON.parse(encoded);
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(encoded);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!this.isApproval(parsed) ||
|
!this.isCommandApproval(parsed) ||
|
||||||
parsed.actorId !== actorId ||
|
parsed.actorId !== actorId ||
|
||||||
parsed.actionDigest !== actionDigest ||
|
parsed.actionDigest !== actionDigest ||
|
||||||
Date.parse(parsed.expiresAt) <= Date.now()
|
!this.isUnexpired(parsed.expiresAt)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
return (await this.redis.del(key)) === 1;
|
return (await this.redis.del(key)) === 1;
|
||||||
@@ -121,18 +195,74 @@ export class CommandAuthorizationService {
|
|||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private isApproval(value: unknown): value is CommandApproval {
|
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 (
|
return (
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
'approvalId' in value &&
|
'approvalId' in value &&
|
||||||
'actionDigest' in value &&
|
'actionDigest' in value &&
|
||||||
'actorId' 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
|
'expiresAt' in value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private key(approvalId: string): string {
|
private key(approvalId: string): string {
|
||||||
return `tess:command-approval:${approvalId}`;
|
return `interaction:command-approval:${approvalId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private runtimeKey(agentName: string, approvalId: string): string {
|
||||||
|
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildService(): CommandExecutorService {
|
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
mockSystemOverride as never,
|
mockSystemOverride as never,
|
||||||
mockSessionGC as never,
|
mockSessionGC as never,
|
||||||
mockRedis as never,
|
redis as never,
|
||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
@@ -131,6 +131,22 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(ttl).toBe(300);
|
expect(ttl).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('/provider login remains available without Redis on the local tier', async () => {
|
||||||
|
const localService = buildService(null);
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'provider',
|
||||||
|
args: 'login anthropic',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await localService.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).not.toContain('token=');
|
||||||
|
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||||
|
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// /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 };
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export class CommandExecutorService {
|
|||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
||||||
|
@Optional()
|
||||||
|
@Inject(COMMANDS_REDIS)
|
||||||
|
private readonly redis: QueueHandle['redis'] | null,
|
||||||
@Inject(BRAIN) private readonly brain: Brain,
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ReloadService))
|
@Inject(forwardRef(() => ReloadService))
|
||||||
@@ -443,14 +446,16 @@ export class CommandExecutorService {
|
|||||||
byte.toString(16).padStart(2, '0'),
|
byte.toString(16).padStart(2, '0'),
|
||||||
).join('');
|
).join('');
|
||||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
if (this.redis) {
|
||||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||||
await this.redis.set(
|
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||||
key,
|
await this.redis.set(
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
key,
|
||||||
'EX',
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
300,
|
'EX',
|
||||||
);
|
300,
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import { ChatModule } from '../chat/chat.module.js';
|
import { 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 { 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';
|
||||||
@@ -15,25 +18,39 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
useFactory: (): QueueHandle => {
|
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||||
|
// On Local tier there is no Redis — skip the ioredis connection.
|
||||||
|
// CommandExecutorService falls back to no-cache for /provider login on local.
|
||||||
|
if (config?.queue?.type === 'local') return null;
|
||||||
return createQueue();
|
return createQueue();
|
||||||
},
|
},
|
||||||
|
inject: [MOSAIC_CONFIG],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: COMMANDS_REDIS,
|
provide: COMMANDS_REDIS,
|
||||||
useFactory: (handle: QueueHandle) => handle.redis,
|
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
CommandAuthorizationService,
|
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(
|
||||||
|
@Optional()
|
||||||
|
@Inject(COMMANDS_QUEUE_HANDLE)
|
||||||
|
private readonly handle: QueueHandle | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle.close().catch(() => {});
|
await this.handle?.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js';
|
|||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||||
|
import { GetController } from './server/verbs/get.controller.js';
|
||||||
|
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||||
@@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js';
|
|||||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
controllers: [
|
||||||
|
EnrollmentController,
|
||||||
|
FederationController,
|
||||||
|
CapabilitiesController,
|
||||||
|
ListController,
|
||||||
|
GetController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
createPgliteDb,
|
||||||
|
missionTasks,
|
||||||
|
missions,
|
||||||
|
projects,
|
||||||
|
runPgliteMigrations,
|
||||||
|
teams,
|
||||||
|
users,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||||
|
import { FederationGetQueryService } from '../get-query.service.js';
|
||||||
|
|
||||||
|
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
|
||||||
|
resource: 'credentials',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
includePersonal: true,
|
||||||
|
teamIds: [],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUBJECT_USER_ID = 'fed-m3-06-subject';
|
||||||
|
const OTHER_USER_ID = 'fed-m3-06-other';
|
||||||
|
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
|
||||||
|
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
|
||||||
|
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
|
||||||
|
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
|
||||||
|
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
|
||||||
|
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
|
||||||
|
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
|
||||||
|
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
|
||||||
|
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
|
||||||
|
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
|
||||||
|
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
|
||||||
|
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
|
||||||
|
|
||||||
|
let dbHandle: DbHandle | undefined;
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
return new FederationGetQueryService({} as Db);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDbService() {
|
||||||
|
if (!dbHandle) {
|
||||||
|
throw new Error('test DB not initialized');
|
||||||
|
}
|
||||||
|
return new FederationGetQueryService(dbHandle.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedNotesFixture() {
|
||||||
|
if (!dbHandle) {
|
||||||
|
throw new Error('test DB not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbHandle.db.insert(users).values([
|
||||||
|
{
|
||||||
|
id: SUBJECT_USER_ID,
|
||||||
|
name: 'Federation Subject',
|
||||||
|
email: `${SUBJECT_USER_ID}@example.test`,
|
||||||
|
emailVerified: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OTHER_USER_ID,
|
||||||
|
name: 'Federation Other',
|
||||||
|
email: `${OTHER_USER_ID}@example.test`,
|
||||||
|
emailVerified: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(teams).values([
|
||||||
|
{
|
||||||
|
id: TEAM_ID,
|
||||||
|
name: 'FED-M3-06 Team',
|
||||||
|
slug: 'fed-m3-06-team',
|
||||||
|
ownerId: SUBJECT_USER_ID,
|
||||||
|
managerId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNAUTHORIZED_TEAM_ID,
|
||||||
|
name: 'FED-M3-06 Unauthorized Team',
|
||||||
|
slug: 'fed-m3-06-unauthorized-team',
|
||||||
|
ownerId: OTHER_USER_ID,
|
||||||
|
managerId: OTHER_USER_ID,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(projects).values([
|
||||||
|
{
|
||||||
|
id: PERSONAL_PROJECT_ID,
|
||||||
|
name: 'FED-M3-06 Personal Project',
|
||||||
|
ownerId: SUBJECT_USER_ID,
|
||||||
|
ownerType: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TEAM_PROJECT_ID,
|
||||||
|
name: 'FED-M3-06 Team Project',
|
||||||
|
teamId: TEAM_ID,
|
||||||
|
ownerType: 'team',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNAUTHORIZED_PROJECT_ID,
|
||||||
|
name: 'FED-M3-06 Unauthorized Project',
|
||||||
|
teamId: UNAUTHORIZED_TEAM_ID,
|
||||||
|
ownerType: 'team',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(missions).values([
|
||||||
|
{
|
||||||
|
id: PERSONAL_MISSION_ID,
|
||||||
|
name: 'FED-M3-06 Personal Mission',
|
||||||
|
projectId: PERSONAL_PROJECT_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TEAM_MISSION_ID,
|
||||||
|
name: 'FED-M3-06 Team Mission',
|
||||||
|
projectId: TEAM_PROJECT_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNAUTHORIZED_MISSION_ID,
|
||||||
|
name: 'FED-M3-06 Unauthorized Mission',
|
||||||
|
projectId: UNAUTHORIZED_PROJECT_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(missionTasks).values([
|
||||||
|
{
|
||||||
|
id: SUBJECT_TEAM_NOTE_ID,
|
||||||
|
missionId: TEAM_MISSION_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
notes: 'subject note on team mission',
|
||||||
|
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OTHER_TEAM_NOTE_ID,
|
||||||
|
missionId: TEAM_MISSION_ID,
|
||||||
|
userId: OTHER_USER_ID,
|
||||||
|
notes: 'other user note on team mission',
|
||||||
|
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||||
|
missionId: PERSONAL_MISSION_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
notes: 'subject note on personal mission',
|
||||||
|
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||||
|
missionId: UNAUTHORIZED_MISSION_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
notes: 'subject note outside grant-visible missions',
|
||||||
|
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FederationGetQueryService', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
|
||||||
|
await runPgliteMigrations(dbHandle);
|
||||||
|
await seedNotesFixture();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await dbHandle?.close();
|
||||||
|
dbHandle = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.evaluateReadAccess({
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
resource: 'credentials',
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
allowed: false,
|
||||||
|
reason: 'credentials federation get access is not implemented in M3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows personal memory reads without requiring team lookup', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.evaluateReadAccess({
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
resource: 'memory',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
allowed: true,
|
||||||
|
access: { includePersonal: true, teamIds: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
|
||||||
|
}
|
||||||
|
).listSubjectTeamIds = listSubjectTeamIds;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.evaluateReadAccess({
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
resource: 'tasks',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
allowed: true,
|
||||||
|
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||||
|
});
|
||||||
|
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
|
||||||
|
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'credentials federation get is not implemented',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for unsupported resources instead of returning undefined', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
...CREDENTIAL_FILTER,
|
||||||
|
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||||
|
},
|
||||||
|
id: 'row-1',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Unsupported federation get resource: unknown-resource',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak another user mission task note through team-scoped get reads', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: false,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
id: OTHER_TEAM_NOTE_ID,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Note is outside the federated scope',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: true,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Note is outside the federated scope',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: false,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
id: SUBJECT_TEAM_NOTE_ID,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
status: 'found',
|
||||||
|
item: {
|
||||||
|
id: SUBJECT_TEAM_NOTE_ID,
|
||||||
|
missionId: TEAM_MISSION_ID,
|
||||||
|
content: 'subject note on team mission',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return subject personal notes when includePersonal is false', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: false,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Note is outside the federated scope',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { RequestMethod } from '@nestjs/common';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||||
|
import type {
|
||||||
|
FederationScopeEvaluationResult,
|
||||||
|
FederationScopeQueryFilter,
|
||||||
|
} from '../../scope.service.js';
|
||||||
|
import { GetController } from '../get.controller.js';
|
||||||
|
import type { FederationGetQueryResult } from '../get-query.service.js';
|
||||||
|
|
||||||
|
const FEDERATION_CONTEXT = {
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||||
|
resource: 'tasks',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
includePersonal: true,
|
||||||
|
teamIds: ['team-1'],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeRequest(): FastifyRequest {
|
||||||
|
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedScope(
|
||||||
|
filter: FederationScopeQueryFilter = TASK_FILTER,
|
||||||
|
): FederationScopeEvaluationResult {
|
||||||
|
return { allowed: true, filter };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeController(opts?: {
|
||||||
|
scopeResult?: FederationScopeEvaluationResult;
|
||||||
|
queryResult?: FederationGetQueryResult;
|
||||||
|
}) {
|
||||||
|
const scope = {
|
||||||
|
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||||
|
};
|
||||||
|
const query = {
|
||||||
|
evaluateReadAccess: vi.fn(),
|
||||||
|
get: vi.fn().mockResolvedValue(
|
||||||
|
opts?.queryResult ?? {
|
||||||
|
status: 'found',
|
||||||
|
item: {
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Federated task',
|
||||||
|
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
controller: new GetController(scope as never, query as never),
|
||||||
|
scope,
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GetController', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
|
||||||
|
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
|
||||||
|
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
|
||||||
|
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
|
||||||
|
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
|
||||||
|
const { controller, scope, query } = makeController();
|
||||||
|
|
||||||
|
const response = await controller.get('tasks', 'task-1', makeRequest());
|
||||||
|
|
||||||
|
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||||
|
context: FEDERATION_CONTEXT,
|
||||||
|
resource: 'tasks',
|
||||||
|
requestedLimit: 1,
|
||||||
|
nativeRbac: query,
|
||||||
|
});
|
||||||
|
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
|
||||||
|
expect(response).toEqual({
|
||||||
|
item: {
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Federated task',
|
||||||
|
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||||
|
_source: 'local',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||||
|
const { controller, scope, query } = makeController();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'unauthorized',
|
||||||
|
message: 'Federation context missing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||||
|
expect(query.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||||
|
const { controller, query } = makeController({
|
||||||
|
scopeResult: {
|
||||||
|
allowed: false,
|
||||||
|
deny: {
|
||||||
|
code: 'resource_excluded',
|
||||||
|
stage: 'resource_exclusion',
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
resource: 'credentials',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'scope_violation',
|
||||||
|
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
expect(query.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
|
||||||
|
const { controller } = makeController({ queryResult: { status: 'not_found' } });
|
||||||
|
|
||||||
|
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: { error: { code: 'not_found' } },
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
|
||||||
|
const { controller } = makeController({
|
||||||
|
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'scope_violation',
|
||||||
|
message: 'Task is outside the federated scope',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when the query layer denies an unsupported resource', async () => {
|
||||||
|
const unsupportedFilter: FederationScopeQueryFilter = {
|
||||||
|
...TASK_FILTER,
|
||||||
|
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||||
|
};
|
||||||
|
const { controller } = makeController({
|
||||||
|
scopeResult: allowedScope(unsupportedFilter),
|
||||||
|
queryResult: {
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Unsupported federation get resource: unknown-resource',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'scope_violation',
|
||||||
|
message: 'Unsupported federation get resource: unknown-resource',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty ids before evaluating scope', async () => {
|
||||||
|
const { controller, scope, query } = makeController();
|
||||||
|
|
||||||
|
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: { error: { code: 'invalid_request' } },
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||||
|
expect(query.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Federation get query layer (FED-M3-06).
|
||||||
|
*
|
||||||
|
* Read-only DB adapter used by GetController after FederationAuthGuard and
|
||||||
|
* FederationScopeService have established the subject user, allowed resource,
|
||||||
|
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
||||||
|
* deferred to M4.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
insights,
|
||||||
|
or,
|
||||||
|
missionTasks,
|
||||||
|
missions,
|
||||||
|
preferences,
|
||||||
|
projects,
|
||||||
|
tasks,
|
||||||
|
teamMembers,
|
||||||
|
type Db,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { DB } from '../../../database/database.module.js';
|
||||||
|
import type {
|
||||||
|
FederationNativeRbacEvaluator,
|
||||||
|
FederationNativeRbacRequest,
|
||||||
|
FederationNativeRbacResult,
|
||||||
|
FederationScopeQueryFilter,
|
||||||
|
} from '../scope.service.js';
|
||||||
|
|
||||||
|
export interface FederationGetQueryRequest {
|
||||||
|
readonly filter: FederationScopeQueryFilter;
|
||||||
|
readonly id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
|
||||||
|
readonly status: 'found';
|
||||||
|
readonly item: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FederationGetQueryNotFoundResult {
|
||||||
|
readonly status: 'not_found';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FederationGetQueryDeniedResult {
|
||||||
|
readonly status: 'denied';
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
|
||||||
|
| FederationGetQueryFoundResult<T>
|
||||||
|
| FederationGetQueryNotFoundResult
|
||||||
|
| FederationGetQueryDeniedResult;
|
||||||
|
|
||||||
|
type RowObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
function firstRow<T>(rows: T[]): T | undefined {
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowBelongsToAccessibleProjectOrMission(
|
||||||
|
row: { projectId?: string | null; missionId?: string | null },
|
||||||
|
projectIds: readonly string[],
|
||||||
|
missionIds: readonly string[],
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
|
||||||
|
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
async evaluateReadAccess(
|
||||||
|
request: FederationNativeRbacRequest,
|
||||||
|
): Promise<FederationNativeRbacResult> {
|
||||||
|
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `${request.resource} federation get access is not implemented in M3`,
|
||||||
|
details: { resource: request.resource },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.resource === 'memory') {
|
||||||
|
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
||||||
|
return { allowed: true, access: { includePersonal: true, teamIds } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T extends RowObject = RowObject>(
|
||||||
|
request: FederationGetQueryRequest,
|
||||||
|
): Promise<FederationGetQueryResult<T>> {
|
||||||
|
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getByResource(
|
||||||
|
filter: FederationScopeQueryFilter,
|
||||||
|
id: string,
|
||||||
|
): Promise<FederationGetQueryResult> {
|
||||||
|
switch (filter.resource) {
|
||||||
|
case 'tasks':
|
||||||
|
return this.getTask(filter, id);
|
||||||
|
case 'notes':
|
||||||
|
return this.getNote(filter, id);
|
||||||
|
case 'memory':
|
||||||
|
return this.getMemory(filter, id);
|
||||||
|
case 'credentials':
|
||||||
|
case 'api_keys':
|
||||||
|
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
status: 'denied',
|
||||||
|
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ teamId: teamMembers.teamId })
|
||||||
|
.from(teamMembers)
|
||||||
|
.where(eq(teamMembers.userId, subjectUserId));
|
||||||
|
|
||||||
|
return rows.map((row) => row.teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
||||||
|
const clauses = [];
|
||||||
|
if (filter.includePersonal) {
|
||||||
|
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
||||||
|
}
|
||||||
|
if (filter.teamIds.length > 0) {
|
||||||
|
// Project team ownership follows TeamsService.canAccessProject: team-owned
|
||||||
|
// rows are authorized through projects.teamId, while ownerId remains the
|
||||||
|
// user who created/bootstrapped the project.
|
||||||
|
clauses.push(
|
||||||
|
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clauses.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
||||||
|
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
||||||
|
if (projectIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ id: missions.id })
|
||||||
|
.from(missions)
|
||||||
|
.where(inArray(missions.projectId, [...projectIds]));
|
||||||
|
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTask(
|
||||||
|
filter: FederationScopeQueryFilter,
|
||||||
|
id: string,
|
||||||
|
): Promise<FederationGetQueryResult> {
|
||||||
|
const row = firstRow(
|
||||||
|
await this.db
|
||||||
|
.select({
|
||||||
|
id: tasks.id,
|
||||||
|
title: tasks.title,
|
||||||
|
description: tasks.description,
|
||||||
|
status: tasks.status,
|
||||||
|
priority: tasks.priority,
|
||||||
|
projectId: tasks.projectId,
|
||||||
|
missionId: tasks.missionId,
|
||||||
|
assignee: tasks.assignee,
|
||||||
|
tags: tasks.tags,
|
||||||
|
dueDate: tasks.dueDate,
|
||||||
|
metadata: tasks.metadata,
|
||||||
|
createdAt: tasks.createdAt,
|
||||||
|
updatedAt: tasks.updatedAt,
|
||||||
|
})
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, id))
|
||||||
|
.limit(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return { status: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||||
|
const missionIds = await this.listMissionIds(projectIds);
|
||||||
|
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
|
||||||
|
return { status: 'denied', reason: 'Task is outside the federated scope' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'found', item: row as RowObject };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getNote(
|
||||||
|
filter: FederationScopeQueryFilter,
|
||||||
|
id: string,
|
||||||
|
): Promise<FederationGetQueryResult> {
|
||||||
|
const row = firstRow(
|
||||||
|
await this.db
|
||||||
|
.select({
|
||||||
|
id: missionTasks.id,
|
||||||
|
missionId: missionTasks.missionId,
|
||||||
|
taskId: missionTasks.taskId,
|
||||||
|
userId: missionTasks.userId,
|
||||||
|
status: missionTasks.status,
|
||||||
|
content: missionTasks.notes,
|
||||||
|
createdAt: missionTasks.createdAt,
|
||||||
|
updatedAt: missionTasks.updatedAt,
|
||||||
|
})
|
||||||
|
.from(missionTasks)
|
||||||
|
.where(eq(missionTasks.id, id))
|
||||||
|
.limit(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row || row.content === null || row.content === '') {
|
||||||
|
return { status: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||||
|
const missionIds = await this.listMissionIds(projectIds);
|
||||||
|
|
||||||
|
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||||
|
// Scope-visible missions must intersect with subject ownership; team scope
|
||||||
|
// narrows mission IDs but never widens note reads to another user's rows.
|
||||||
|
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
|
||||||
|
return { status: 'denied', reason: 'Note is outside the federated scope' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = { ...row } as RowObject;
|
||||||
|
delete item['userId'];
|
||||||
|
return { status: 'found', item };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMemory(
|
||||||
|
filter: FederationScopeQueryFilter,
|
||||||
|
id: string,
|
||||||
|
): Promise<FederationGetQueryResult> {
|
||||||
|
const [insightRow, preferenceRow] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.select({
|
||||||
|
id: insights.id,
|
||||||
|
userId: insights.userId,
|
||||||
|
kind: insights.source,
|
||||||
|
content: insights.content,
|
||||||
|
category: insights.category,
|
||||||
|
relevanceScore: insights.relevanceScore,
|
||||||
|
metadata: insights.metadata,
|
||||||
|
createdAt: insights.createdAt,
|
||||||
|
updatedAt: insights.updatedAt,
|
||||||
|
})
|
||||||
|
.from(insights)
|
||||||
|
.where(eq(insights.id, id))
|
||||||
|
.limit(1)
|
||||||
|
.then(firstRow),
|
||||||
|
this.db
|
||||||
|
.select({
|
||||||
|
id: preferences.id,
|
||||||
|
userId: preferences.userId,
|
||||||
|
kind: preferences.category,
|
||||||
|
key: preferences.key,
|
||||||
|
value: preferences.value,
|
||||||
|
source: preferences.source,
|
||||||
|
mutable: preferences.mutable,
|
||||||
|
createdAt: preferences.createdAt,
|
||||||
|
updatedAt: preferences.updatedAt,
|
||||||
|
})
|
||||||
|
.from(preferences)
|
||||||
|
.where(eq(preferences.id, id))
|
||||||
|
.limit(1)
|
||||||
|
.then(firstRow),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const candidates = [insightRow, preferenceRow].filter(
|
||||||
|
(row): row is NonNullable<typeof row> => row !== undefined,
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return { status: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter.includePersonal) {
|
||||||
|
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
|
||||||
|
if (!accessible) {
|
||||||
|
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = { ...accessible } as RowObject;
|
||||||
|
delete item['userId'];
|
||||||
|
return { status: 'found', item };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Federation get verb (FED-M3-06).
|
||||||
|
*
|
||||||
|
* POST /api/federation/v1/get/:resource/:id
|
||||||
|
*
|
||||||
|
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||||
|
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||||
|
* the read-only query layer fetches one local row and tags it with `_source`.
|
||||||
|
* Read audit-log writes are deferred to M4; this controller does not persist
|
||||||
|
* request or response bodies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import {
|
||||||
|
FederationInvalidRequestError,
|
||||||
|
FederationNotFoundError,
|
||||||
|
FederationScopeViolationError,
|
||||||
|
FederationUnauthorizedError,
|
||||||
|
SOURCE_LOCAL,
|
||||||
|
type FederationGetResponse,
|
||||||
|
type SourceTag,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||||
|
import '../federation-context.js';
|
||||||
|
import { FederationScopeService } from '../scope.service.js';
|
||||||
|
import { FederationGetQueryService } from './get-query.service.js';
|
||||||
|
|
||||||
|
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||||
|
|
||||||
|
function scopeDenyToHttpException(deny: {
|
||||||
|
readonly statusCode: 400 | 403;
|
||||||
|
readonly message: string;
|
||||||
|
}): HttpException {
|
||||||
|
const ErrorClass =
|
||||||
|
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
|
||||||
|
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api/federation/v1/get')
|
||||||
|
@UseGuards(FederationAuthGuard)
|
||||||
|
export class GetController {
|
||||||
|
constructor(
|
||||||
|
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||||
|
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post(':resource/:id')
|
||||||
|
async get(
|
||||||
|
@Param('resource') resource: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
): Promise<FederationGetResponse<FederatedRow>> {
|
||||||
|
if (!request.federationContext) {
|
||||||
|
throw new HttpException(
|
||||||
|
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (id.trim().length === 0) {
|
||||||
|
throw new HttpException(
|
||||||
|
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeResult = await this.scope.evaluateAccess({
|
||||||
|
context: request.federationContext,
|
||||||
|
resource,
|
||||||
|
requestedLimit: 1,
|
||||||
|
nativeRbac: this.query,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scopeResult.allowed) {
|
||||||
|
throw scopeDenyToHttpException(scopeResult.deny);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.query.get({ filter: scopeResult.filter, id });
|
||||||
|
if (result.status === 'not_found') {
|
||||||
|
throw new HttpException(
|
||||||
|
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.status === 'denied') {
|
||||||
|
throw new HttpException(
|
||||||
|
new FederationScopeViolationError(result.reason, {
|
||||||
|
resource,
|
||||||
|
id,
|
||||||
|
grantId: request.federationContext.grantId,
|
||||||
|
peerId: request.federationContext.peerId,
|
||||||
|
subjectUserId: request.federationContext.subjectUserId,
|
||||||
|
}).toEnvelope(),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item: { ...result.item, _source: SOURCE_LOCAL } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import { SessionGCService } from './session-gc.service.js';
|
import { SessionGCService } from './session-gc.service.js';
|
||||||
import { REDIS } from './gc.tokens.js';
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: GC_QUEUE_HANDLE,
|
provide: GC_QUEUE_HANDLE,
|
||||||
useFactory: (): QueueHandle => {
|
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||||
|
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
||||||
|
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
||||||
|
if (config?.queue?.type === 'local') return null;
|
||||||
return createQueue();
|
return createQueue();
|
||||||
},
|
},
|
||||||
|
inject: [MOSAIC_CONFIG],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: REDIS,
|
provide: REDIS,
|
||||||
useFactory: (handle: QueueHandle) => handle.redis,
|
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||||
inject: [GC_QUEUE_HANDLE],
|
inject: [GC_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
SessionGCService,
|
SessionGCService,
|
||||||
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
exports: [SessionGCService],
|
exports: [SessionGCService],
|
||||||
})
|
})
|
||||||
export class GCModule implements OnApplicationShutdown {
|
export class GCModule implements OnApplicationShutdown {
|
||||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
constructor(
|
||||||
|
@Optional()
|
||||||
|
@Inject(GC_QUEUE_HANDLE)
|
||||||
|
private readonly handle: QueueHandle | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle.close().catch(() => {});
|
await this.handle?.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe('SessionGCService', () => {
|
|||||||
} as never;
|
} as never;
|
||||||
const payload = { command: 'gc', conversationId: 'owned' };
|
const payload = { command: 'gc', conversationId: 'owned' };
|
||||||
const approval = await authorization.createApproval(command, payload, 'admin-1');
|
const approval = await authorization.createApproval(command, payload, 'admin-1');
|
||||||
const approvalKey = `tess:command-approval:${approval!.approvalId}`;
|
const approvalKey = `interaction:command-approval:${approval!.approvalId}`;
|
||||||
const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService);
|
const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService);
|
||||||
|
|
||||||
await gc.collect('owned');
|
await gc.collect('owned');
|
||||||
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
|
|||||||
).resolves.toEqual({ allowed: true });
|
).resolves.toEqual({ allowed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
|
||||||
|
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
|
||||||
|
|
||||||
|
const result = await localService.collect('local-session');
|
||||||
|
|
||||||
|
expect(result.sessionId).toBe('local-session');
|
||||||
|
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||||
|
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
||||||
|
'local-session',
|
||||||
|
expect.any(Date),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('collect() returns sessionId in result', async () => {
|
it('collect() returns sessionId in result', async () => {
|
||||||
const result = await service.collect('test-session-id');
|
const result = await service.collect('test-session-id');
|
||||||
expect(result.sessionId).toBe('test-session-id');
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
||||||
|
@Optional()
|
||||||
|
@Inject(REDIS)
|
||||||
|
private readonly redis: QueueHandle['redis'] | null,
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -29,8 +32,10 @@ export class SessionGCService {
|
|||||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||||
* duration, which can cause latency spikes under production key volumes.
|
* duration, which can cause latency spikes under production key volumes.
|
||||||
|
* Returns an empty population on the Local tier where Redis is disabled.
|
||||||
*/
|
*/
|
||||||
private async scanKeys(pattern: string): Promise<string[]> {
|
private async scanKeys(pattern: string): Promise<string[]> {
|
||||||
|
if (!this.redis) return [];
|
||||||
const collected: string[] = [];
|
const collected: string[] = [];
|
||||||
let cursor = '0';
|
let cursor = '0';
|
||||||
do {
|
do {
|
||||||
@@ -47,12 +52,14 @@ export class SessionGCService {
|
|||||||
async collect(sessionId: string): Promise<GCResult> {
|
async collect(sessionId: string): Promise<GCResult> {
|
||||||
const result: GCResult = { sessionId, cleaned: {} };
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
// 1. Valkey: delete all session-scoped keys
|
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
||||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
if (this.redis) {
|
||||||
const valkeyKeys = await this.scanKeys(pattern);
|
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||||
if (valkeyKeys.length > 0) {
|
const valkeyKeys = await this.scanKeys(pattern);
|
||||||
await this.redis.del(...valkeyKeys);
|
if (valkeyKeys.length > 0) {
|
||||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
await this.redis.del(...valkeyKeys);
|
||||||
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. PG: demote hot-tier agent logs for this session only.
|
// 2. PG: demote hot-tier agent logs for this session only.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { HealthController } from './health.controller.js';
|
||||||
|
|
||||||
|
describe('HealthController', (): void => {
|
||||||
|
it('exposes liveness and readiness without configuration details', (): void => {
|
||||||
|
const controller = new HealthController();
|
||||||
|
|
||||||
|
expect(controller.check()).toEqual({ status: 'ok' });
|
||||||
|
expect(controller.ready()).toEqual({ status: 'ready' });
|
||||||
|
expect(JSON.stringify(controller.ready())).not.toContain('credential');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,4 +6,10 @@ export class HealthController {
|
|||||||
check(): { status: string } {
|
check(): { status: string } {
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Readiness intentionally exposes no configuration, provider, or credential details. */
|
||||||
|
@Get('ready')
|
||||||
|
ready(): { status: string } {
|
||||||
|
return { status: 'ready' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
|
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
|
||||||
|
if (!this.queueService.isEnabled()) {
|
||||||
|
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||||
|
|
||||||
@@ -39,7 +45,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||||
await this.summarization.runSummarization();
|
await this.summarization.runSummarization();
|
||||||
});
|
});
|
||||||
this.registeredWorkers.push(summarizationWorker);
|
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||||
|
|
||||||
// M6-005: Tier management repeatable job
|
// M6-005: Tier management repeatable job
|
||||||
await this.queueService.addRepeatableJob(
|
await this.queueService.addRepeatableJob(
|
||||||
@@ -51,7 +57,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||||
await this.summarization.runTierManagement();
|
await this.summarization.runTierManagement();
|
||||||
});
|
});
|
||||||
this.registeredWorkers.push(tierWorker);
|
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||||
|
|
||||||
// Retire any repeatable global GC schedule created by older deployments.
|
// Retire any repeatable global GC schedule created by older deployments.
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
createMemory,
|
createMemory,
|
||||||
type Memory,
|
type Memory,
|
||||||
createMemoryAdapter,
|
createMemoryAdapter,
|
||||||
|
createOperatorMemoryPlugin,
|
||||||
type MemoryAdapter,
|
type MemoryAdapter,
|
||||||
type MemoryConfig,
|
type MemoryConfig,
|
||||||
|
type OperatorMemoryPlugin,
|
||||||
} from '@mosaicstack/memory';
|
} from '@mosaicstack/memory';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaicstack/db';
|
||||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||||
@@ -14,6 +16,9 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
|
|||||||
import { MEMORY } from './memory.tokens.js';
|
import { MEMORY } from './memory.tokens.js';
|
||||||
import { MemoryController } from './memory.controller.js';
|
import { MemoryController } from './memory.controller.js';
|
||||||
import { EmbeddingService } from './embedding.service.js';
|
import { EmbeddingService } from './embedding.service.js';
|
||||||
|
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||||
|
|
||||||
|
export const OPERATOR_MEMORY_PLUGIN = 'OPERATOR_MEMORY_PLUGIN';
|
||||||
|
|
||||||
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
|
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
|
||||||
|
|
||||||
@@ -38,9 +43,24 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter)
|
|||||||
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
||||||
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
|
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: OPERATOR_MEMORY_PLUGIN,
|
||||||
|
useFactory: (adapter: MemoryAdapter): OperatorMemoryPlugin | null => {
|
||||||
|
const instanceId = process.env['MOSAIC_OPERATOR_MEMORY_INSTANCE_ID']?.trim();
|
||||||
|
const namespace = process.env['MOSAIC_OPERATOR_MEMORY_NAMESPACE']?.trim();
|
||||||
|
if (!instanceId || !namespace) return null;
|
||||||
|
return createOperatorMemoryPlugin({
|
||||||
|
adapter,
|
||||||
|
instanceId,
|
||||||
|
namespace,
|
||||||
|
redact: (content) => redactSensitiveContent(content).content,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
inject: [MEMORY_ADAPTER],
|
||||||
|
},
|
||||||
EmbeddingService,
|
EmbeddingService,
|
||||||
],
|
],
|
||||||
controllers: [MemoryController],
|
controllers: [MemoryController],
|
||||||
exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService],
|
exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
|
||||||
})
|
})
|
||||||
export class MemoryModule {}
|
export class MemoryModule {}
|
||||||
|
|||||||
@@ -1,13 +1,141 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
createDiscordIngressEnvelope,
|
createDiscordIngressEnvelope,
|
||||||
verifyDiscordIngressEnvelope,
|
verifyDiscordIngressEnvelope,
|
||||||
|
DiscordPlugin,
|
||||||
type DiscordIngressPayload,
|
type DiscordIngressPayload,
|
||||||
|
parseDiscordInteractionBindings,
|
||||||
|
resolveDiscordInteractionActorId,
|
||||||
|
resolveDiscordInteractionBinding,
|
||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
|
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
|
||||||
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||||
|
|
||||||
const SERVICE_TOKEN = 'test-service-token';
|
const SERVICE_TOKEN = 'test-service-token';
|
||||||
|
const ENV_KEYS = [
|
||||||
|
'DISCORD_SERVICE_TOKEN',
|
||||||
|
'DISCORD_SERVICE_USER_ID',
|
||||||
|
'DISCORD_SERVICE_TENANT_ID',
|
||||||
|
'DISCORD_INTERACTION_BINDINGS',
|
||||||
|
'DISCORD_ALLOWED_GUILD_IDS',
|
||||||
|
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||||
|
'DISCORD_ALLOWED_USER_IDS',
|
||||||
|
'MOSAIC_AGENT_NAME',
|
||||||
|
'MOSAIC_AGENT_CONFIG_ID',
|
||||||
|
] as const;
|
||||||
|
const savedEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
||||||
|
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
||||||
|
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
||||||
|
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
||||||
|
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
|
process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova';
|
||||||
|
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': {
|
||||||
|
role: role === 'admin' ? 'admin' : 'operator',
|
||||||
|
mosaicUserId: 'mosaic-admin-001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
const value = savedEnv.get(key);
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
savedEnv.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discordGateway(role: 'admin' | 'member'): {
|
||||||
|
gateway: ChatGateway;
|
||||||
|
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
|
||||||
|
consumedActions: Array<{ actorId: string; correlationId: string }>;
|
||||||
|
durable: { getSnapshot: ReturnType<typeof vi.fn> };
|
||||||
|
audit: { record: ReturnType<typeof vi.fn> };
|
||||||
|
} {
|
||||||
|
const authorization = commandAuthorization(role);
|
||||||
|
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
|
||||||
|
const durable = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue({
|
||||||
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const audit = { record: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const runtimeRegistry = new RuntimeProviderService(
|
||||||
|
{
|
||||||
|
require: () => ({
|
||||||
|
capabilities: async () => ({ supported: ['session.terminate'] }),
|
||||||
|
terminate: async () => undefined,
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
{ record: async () => undefined } as never,
|
||||||
|
{
|
||||||
|
consume: async (approvalId, action) => {
|
||||||
|
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
|
||||||
|
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
gateway: new ChatGateway(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
authorization,
|
||||||
|
runtimeRegistry,
|
||||||
|
durable as never,
|
||||||
|
audit as never,
|
||||||
|
),
|
||||||
|
client: { data: { discordService: true }, emit: vi.fn() },
|
||||||
|
consumedActions,
|
||||||
|
durable,
|
||||||
|
audit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingressEnvelope(
|
||||||
|
content: string,
|
||||||
|
messageId: string,
|
||||||
|
overrides: Partial<DiscordIngressPayload> = {},
|
||||||
|
): ReturnType<typeof createDiscordIngressEnvelope> {
|
||||||
|
return createDiscordIngressEnvelope(
|
||||||
|
createPayload({ content, messageId, ...overrides }),
|
||||||
|
SERVICE_TOKEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
||||||
return {
|
return {
|
||||||
@@ -16,13 +144,51 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
|||||||
guildId: 'guild-001',
|
guildId: 'guild-001',
|
||||||
channelId: 'channel-001',
|
channelId: 'channel-001',
|
||||||
userId: 'user-001',
|
userId: 'user-001',
|
||||||
conversationId: 'discord-channel-001',
|
conversationId: 'Nova:discord:channel-001',
|
||||||
content: 'hello Tess',
|
content: 'hello Tess',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Discord ingress security', () => {
|
describe('Discord ingress security', () => {
|
||||||
|
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
||||||
|
const [binding] = parseDiscordInteractionBindings(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: { 'user-001': 'admin' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
|
||||||
|
).toEqual(binding);
|
||||||
|
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds a differently named configured interaction instance without code changes', () => {
|
||||||
|
const binding = resolveDiscordInteractionBinding(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'guild-001',
|
||||||
|
'channel-001',
|
||||||
|
'user-001',
|
||||||
|
'send',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(binding?.instanceId).toBe('Nova');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts only the configured Discord service identity', () => {
|
it('accepts only the configured Discord service identity', () => {
|
||||||
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
||||||
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
||||||
@@ -86,4 +252,464 @@ describe('Discord ingress security', () => {
|
|||||||
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
||||||
expect(replayProtector.size).toBe(2);
|
expect(replayProtector.size).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client, consumedActions } = discordGateway('admin');
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'approve-message', {
|
||||||
|
correlationId: 'approval-ingress-correlation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', {
|
||||||
|
correlationId: 'stop-ingress-correlation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||||
|
correlationId: 'stop-ingress-correlation',
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
expect(consumedActions).toEqual([
|
||||||
|
{
|
||||||
|
actorId: 'mosaic-admin-001',
|
||||||
|
correlationId: expect.stringMatching(/^discord-action:v1:/),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audits a Discord mint-side authorization denial', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client, audit } = discordGateway('member');
|
||||||
|
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'denied-approve'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||||
|
correlationId: 'correlation-001',
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
outcome: 'denied',
|
||||||
|
operation: 'session.terminate',
|
||||||
|
errorCode: 'policy_denied',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects approval when the durable session targets a different logical agent', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client, durable } = discordGateway('admin');
|
||||||
|
durable.getSnapshot.mockResolvedValueOnce({
|
||||||
|
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'mismatched-agent-approve'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||||
|
correlationId: 'correlation-001',
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects privileged envelopes with a forged current conversation route', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client } = discordGateway('admin');
|
||||||
|
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'forged-approval-route', {
|
||||||
|
conversationId: 'Nova:discord:other-channel',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/stop forged', 'forged-stop-route', {
|
||||||
|
conversationId: 'Nova:discord:other-channel',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything());
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client } = discordGateway('member');
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'member-approve'),
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||||
|
correlationId: 'correlation-001',
|
||||||
|
success: false,
|
||||||
|
approvalId: undefined,
|
||||||
|
expiresAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/stop forged', 'unpaired-stop'),
|
||||||
|
);
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects replaying a Discord-created termination approval', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client } = discordGateway('admin');
|
||||||
|
await gateway.handleDiscordApproval(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('/approve', 'replay-approve', {
|
||||||
|
correlationId: 'replay-approval-correlation',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const approval = client.emit.mock.calls.find(
|
||||||
|
([event]) => event === 'discord:approval',
|
||||||
|
)?.[1] as {
|
||||||
|
approvalId: string;
|
||||||
|
};
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', {
|
||||||
|
correlationId: 'replay-stop-correlation-one',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await gateway.handleDiscordStop(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', {
|
||||||
|
correlationId: 'replay-stop-correlation-two',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
|
||||||
|
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'https://user:[email protected]/diagram.png',
|
||||||
|
'https://cdn.example.test/diagram.png?token=secret',
|
||||||
|
'https://cdn.example.test/diagram.png?X-Amz-Signature=secret',
|
||||||
|
'https://cdn.example.test/diagram.png?auth=secret',
|
||||||
|
'https://cdn.example.test/diagram.png?hm=secret',
|
||||||
|
])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client } = discordGateway('admin');
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('', `credential-url-${url.length}`, {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
attachments: [
|
||||||
|
{ id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
instanceId: 'Orion',
|
||||||
|
agentConfigId: 'agent-config-orion',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-002',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const brain = {
|
||||||
|
agents: {
|
||||||
|
findById: vi.fn((id: string) =>
|
||||||
|
Promise.resolve({
|
||||||
|
id,
|
||||||
|
name: id === 'agent-config-orion' ? 'Orion' : 'Nova',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const routingEngine = { resolve: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
agentService as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
routingEngine as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-new-session',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('start configured session', 'configured-session-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('start second configured session', 'configured-session-002', {
|
||||||
|
channelId: 'channel-002',
|
||||||
|
conversationId: 'Orion:discord:channel-002',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createSession).toHaveBeenCalledWith(
|
||||||
|
'Nova:discord:channel-001',
|
||||||
|
expect.objectContaining({
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
userId: 'discord-service',
|
||||||
|
tenantId: 'tenant-discord',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(createSession).toHaveBeenCalledWith(
|
||||||
|
'Orion:discord:channel-002',
|
||||||
|
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
|
||||||
|
);
|
||||||
|
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains validated persisted attachments in resumed conversation history', async () => {
|
||||||
|
const attachment = {
|
||||||
|
id: 'attachment-history',
|
||||||
|
name: 'diagram.png',
|
||||||
|
url: 'https://cdn.example.test/diagram.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
sizeBytes: 4_096,
|
||||||
|
};
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
conversations: {
|
||||||
|
findMessages: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: '',
|
||||||
|
createdAt: new Date('2026-07-14T12:00:00.000Z'),
|
||||||
|
metadata: { channelAttachments: [attachment] },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
) as unknown as {
|
||||||
|
loadConversationHistory(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Array<{ attachments?: readonly (typeof attachment)[] }>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
gateway.loadConversationHistory('Nova:discord:channel-001', 'discord-service'),
|
||||||
|
).resolves.toEqual([expect.objectContaining({ attachments: [attachment] })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed signed attachment payloads before gateway dispatch', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const { gateway, client } = discordGateway('admin');
|
||||||
|
const malformedPayload: Record<string, unknown> = {
|
||||||
|
...createPayload({
|
||||||
|
messageId: 'malformed-attachments-001',
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
}),
|
||||||
|
attachments: { id: 'not-an-array' },
|
||||||
|
};
|
||||||
|
const envelope = createDiscordIngressEnvelope(
|
||||||
|
malformedPayload as unknown as DiscordIngressPayload,
|
||||||
|
SERVICE_TOKEN,
|
||||||
|
);
|
||||||
|
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const addMessage = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const session = {
|
||||||
|
provider: 'test-provider',
|
||||||
|
modelId: 'test-model',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(session),
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const brain = {
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
agentService as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-001',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const attachment = {
|
||||||
|
id: 'attachment-001',
|
||||||
|
name: 'diagram.png',
|
||||||
|
url: 'https://cdn.example.test/diagram.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
sizeBytes: 4_096,
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('', 'attachment-message-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
attachments: [attachment],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedAttachment = {
|
||||||
|
id: attachment.id,
|
||||||
|
name: attachment.name,
|
||||||
|
url: attachment.url,
|
||||||
|
mimeType: attachment.contentType,
|
||||||
|
sizeBytes: attachment.sizeBytes,
|
||||||
|
};
|
||||||
|
expect(prompt).toHaveBeenCalledWith(
|
||||||
|
'Nova:discord:channel-001',
|
||||||
|
'',
|
||||||
|
{ userId: 'discord-service', tenantId: 'tenant-discord' },
|
||||||
|
[expectedAttachment],
|
||||||
|
);
|
||||||
|
expect(addMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
metadata: expect.objectContaining({ channelAttachments: [expectedAttachment] }),
|
||||||
|
}),
|
||||||
|
'discord-service',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a thread message through its allowed bound parent channel', () => {
|
||||||
|
const emitted = vi.fn();
|
||||||
|
const plugin = new DiscordPlugin({
|
||||||
|
token: 'unused',
|
||||||
|
gatewayUrl: 'http://unused',
|
||||||
|
serviceToken: SERVICE_TOKEN,
|
||||||
|
allowedGuildIds: ['guild-001'],
|
||||||
|
allowedChannelIds: ['channel-001'],
|
||||||
|
allowedUserIds: ['user-001'],
|
||||||
|
interactionBindings: [
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const internals = plugin as unknown as {
|
||||||
|
client: { user: { id: string } };
|
||||||
|
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
||||||
|
handleDiscordMessage(message: unknown): void;
|
||||||
|
};
|
||||||
|
internals.client = { user: { id: 'bot-001' } };
|
||||||
|
internals.socket = { connected: true, emit: emitted };
|
||||||
|
internals.handleDiscordMessage({
|
||||||
|
id: 'thread-message',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'thread-001',
|
||||||
|
author: { id: 'user-001', bot: false },
|
||||||
|
mentions: { has: () => true },
|
||||||
|
content: '<@bot-001> hello from thread',
|
||||||
|
channel: { parentId: 'channel-001' },
|
||||||
|
attachments: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, envelope] = emitted.mock.calls[0] as [
|
||||||
|
string,
|
||||||
|
ReturnType<typeof createDiscordIngressEnvelope>,
|
||||||
|
];
|
||||||
|
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
type OnModuleInit,
|
type OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
|
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
|
||||||
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
||||||
import { PluginService } from './plugin.service.js';
|
import { PluginService } from './plugin.service.js';
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
@@ -61,6 +61,16 @@ function requiredDiscordAllowlist(name: string): string[] {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalPositiveInteger(name: string): number | undefined {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`${name} must be a positive integer when configured`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function createPluginRegistry(): IChannelPlugin[] {
|
function createPluginRegistry(): IChannelPlugin[] {
|
||||||
const plugins: IChannelPlugin[] = [];
|
const plugins: IChannelPlugin[] = [];
|
||||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||||
@@ -82,9 +92,16 @@ function createPluginRegistry(): IChannelPlugin[] {
|
|||||||
guildId: discordGuildId,
|
guildId: discordGuildId,
|
||||||
gatewayUrl: discordGatewayUrl,
|
gatewayUrl: discordGatewayUrl,
|
||||||
serviceToken: discordServiceToken,
|
serviceToken: discordServiceToken,
|
||||||
|
messageRateLimitPerMinute: optionalPositiveInteger(
|
||||||
|
'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE',
|
||||||
|
),
|
||||||
|
threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'),
|
||||||
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||||
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||||
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||||
|
interactionBindings: parseDiscordInteractionBindings(
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'],
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { SystemOverrideService } from './system-override.service.js';
|
||||||
|
|
||||||
|
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
|
||||||
|
|
||||||
|
describe('SystemOverrideService local tier', () => {
|
||||||
|
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
|
||||||
|
const service = new SystemOverrideService(localConfig);
|
||||||
|
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
|
||||||
|
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
|
||||||
|
|
||||||
|
await service.set('shared-session', 'first override', firstScope);
|
||||||
|
await service.set('shared-session', 'second override', secondScope);
|
||||||
|
|
||||||
|
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
|
||||||
|
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||||
|
|
||||||
|
await service.clear('shared-session', firstScope);
|
||||||
|
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
|
||||||
|
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
|
|
||||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||||
@@ -15,16 +17,45 @@ interface OverrideFragment {
|
|||||||
addedAt: number;
|
addedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
interface LocalOverrideEntry {
|
||||||
export class SystemOverrideService {
|
condensed: string;
|
||||||
private readonly logger = new Logger(SystemOverrideService.name);
|
fragments: OverrideFragment[];
|
||||||
private readonly handle: QueueHandle;
|
}
|
||||||
|
|
||||||
constructor() {
|
@Injectable()
|
||||||
this.handle = createQueue();
|
export class SystemOverrideService implements OnApplicationShutdown {
|
||||||
|
private readonly logger = new Logger(SystemOverrideService.name);
|
||||||
|
private readonly handle: QueueHandle | null;
|
||||||
|
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
||||||
|
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Optional()
|
||||||
|
@Inject(MOSAIC_CONFIG)
|
||||||
|
private readonly mosaicConfig: MosaicConfig | null,
|
||||||
|
) {
|
||||||
|
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onApplicationShutdown(): Promise<void> {
|
||||||
|
await this.handle?.close().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) {
|
||||||
|
const key = scopedSessionId(sessionId, scope);
|
||||||
|
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
|
||||||
|
entry.fragments.push({ text: override, addedAt: Date.now() });
|
||||||
|
entry.condensed = await this.condenseOverrides(
|
||||||
|
entry.fragments.map((fragment) => fragment.text),
|
||||||
|
);
|
||||||
|
this.localStore.set(key, entry);
|
||||||
|
this.logger.debug(
|
||||||
|
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Load existing fragments
|
// Load existing fragments
|
||||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||||
const fragments: OverrideFragment[] = existing
|
const fragments: OverrideFragment[] = existing
|
||||||
@@ -54,10 +85,14 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||||
|
if (!this.handle) {
|
||||||
|
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
||||||
|
}
|
||||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) return;
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
@@ -65,6 +100,11 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) {
|
||||||
|
this.localStore.delete(scopedSessionId(sessionId, scope));
|
||||||
|
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.handle.redis.del(
|
await this.handle.redis.del(
|
||||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { QueueService } from './queue.service.js';
|
||||||
|
|
||||||
|
const localConfig = {
|
||||||
|
queue: { type: 'local' },
|
||||||
|
} as MosaicConfig;
|
||||||
|
|
||||||
|
describe('QueueService local tier', () => {
|
||||||
|
it('disables BullMQ and treats queue operations as local no-ops', async () => {
|
||||||
|
const service = new QueueService(null, localConfig);
|
||||||
|
|
||||||
|
expect(service.isEnabled()).toBe(false);
|
||||||
|
expect(service.getQueue('mosaic-test')).toBeNull();
|
||||||
|
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
|
||||||
|
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
|
||||||
|
await expect(service.listJobs()).resolves.toEqual([]);
|
||||||
|
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'BullMQ is disabled on local tier.',
|
||||||
|
});
|
||||||
|
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'BullMQ is disabled on local tier.',
|
||||||
|
});
|
||||||
|
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'BullMQ is disabled on local tier.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -108,21 +110,42 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly connection: ConnectionOptions;
|
private readonly connection: ConnectionOptions;
|
||||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||||
|
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
||||||
|
private readonly enabled: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(LOG_SERVICE)
|
@Inject(LOG_SERVICE)
|
||||||
private readonly logService: LogService | null,
|
private readonly logService: LogService | null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(MOSAIC_CONFIG)
|
||||||
|
private readonly mosaicConfig: MosaicConfig | null,
|
||||||
) {
|
) {
|
||||||
this.connection = getConnection();
|
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
||||||
|
this.connection = this.enabled
|
||||||
|
? getConnection()
|
||||||
|
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
this.logger.log('QueueService initialised (BullMQ)');
|
if (this.enabled) {
|
||||||
|
this.logger.log('QueueService initialised (BullMQ)');
|
||||||
|
} else {
|
||||||
|
this.logger.log(
|
||||||
|
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
await this.closeAll();
|
if (this.enabled) {
|
||||||
|
await this.closeAll();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a BullMQ Queue for the given queue name.
|
* Get or create a BullMQ Queue for the given queue name.
|
||||||
|
* Returns null on Local tier where BullMQ is disabled.
|
||||||
*/
|
*/
|
||||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
||||||
|
if (!this.enabled) return null;
|
||||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||||
if (!queue) {
|
if (!queue) {
|
||||||
queue = new Queue<T>(name, { connection: this.connection });
|
queue = new Queue<T>(name, { connection: this.connection });
|
||||||
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Add a BullMQ repeatable job (cron-style).
|
* Add a BullMQ repeatable job (cron-style).
|
||||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||||
|
* No-op on Local tier.
|
||||||
*/
|
*/
|
||||||
async addRepeatableJob<T extends MosaicJobData>(
|
async addRepeatableJob<T extends MosaicJobData>(
|
||||||
queueName: string,
|
queueName: string,
|
||||||
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
data: T,
|
data: T,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const queue = this.getQueue<T>(queueName);
|
if (!this.enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queue = this.getQueue<T>(queueName)!;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await (queue as Queue<any>).add(jobName, data, {
|
await (queue as Queue<any>).add(jobName, data, {
|
||||||
repeat: { pattern: cronExpression },
|
repeat: { pattern: cronExpression },
|
||||||
@@ -167,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* safe retirement of previously registered system-wide jobs.
|
* safe retirement of previously registered system-wide jobs.
|
||||||
*/
|
*/
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const queue = this.getQueue(queueName);
|
const queue = this.getQueue(queueName);
|
||||||
|
if (!queue) return 0;
|
||||||
const jobs = await queue.getRepeatableJobs();
|
const jobs = await queue.getRepeatableJobs();
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
||||||
@@ -182,8 +221,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Register a Worker for the given queue name with error handling and
|
* Register a Worker for the given queue name with error handling and
|
||||||
* exponential backoff.
|
* exponential backoff.
|
||||||
|
* Returns null on Local tier where BullMQ is disabled.
|
||||||
*/
|
*/
|
||||||
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
registerWorker<T extends MosaicJobData>(
|
||||||
|
queueName: string,
|
||||||
|
handler: JobHandler<T>,
|
||||||
|
): Worker<T> | null {
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const worker = new Worker<T>(
|
const worker = new Worker<T>(
|
||||||
queueName,
|
queueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@@ -240,8 +289,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return queue health statistics for all managed queues.
|
* Return queue health statistics for all managed queues.
|
||||||
|
* Returns an empty healthy result on Local tier.
|
||||||
*/
|
*/
|
||||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return { queues: {}, healthy: true };
|
||||||
|
}
|
||||||
const queues: QueueHealthStatus['queues'] = {};
|
const queues: QueueHealthStatus['queues'] = {};
|
||||||
let healthy = true;
|
let healthy = true;
|
||||||
|
|
||||||
@@ -272,8 +325,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* List jobs across all managed queues, optionally filtered by status.
|
* List jobs across all managed queues, optionally filtered by status.
|
||||||
* BullMQ jobs are fetched by state type from each queue.
|
* BullMQ jobs are fetched by state type from each queue.
|
||||||
|
* Returns empty array on Local tier.
|
||||||
*/
|
*/
|
||||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||||
|
if (!this.enabled) return [];
|
||||||
const jobs: JobDto[] = [];
|
const jobs: JobDto[] = [];
|
||||||
const states: JobStatus[] = status
|
const states: JobStatus[] = status
|
||||||
? [status]
|
? [status]
|
||||||
@@ -300,8 +355,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||||
* job IDs are not globally unique — they are scoped to their queue.
|
* job IDs are not globally unique — they are scoped to their queue.
|
||||||
|
* Returns an error on Local tier.
|
||||||
*/
|
*/
|
||||||
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||||
const sep = compositeId.lastIndexOf('__');
|
const sep = compositeId.lastIndexOf('__');
|
||||||
if (sep === -1) {
|
if (sep === -1) {
|
||||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||||
@@ -333,6 +390,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Pause a queue by name.
|
* Pause a queue by name.
|
||||||
*/
|
*/
|
||||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||||
const queue = this.queues.get(name);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.pause();
|
await queue.pause();
|
||||||
@@ -344,6 +402,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Resume a paused queue by name.
|
* Resume a paused queue by name.
|
||||||
*/
|
*/
|
||||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||||
const queue = this.queues.get(name);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.resume();
|
await queue.resume();
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mosaic</title>
|
||||||
|
<meta name="description" content="Mosaic Stack Dashboard" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
||||||
|
/>
|
||||||
|
<script>
|
||||||
|
// set data-theme before first paint so the stored theme never flashes
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||||||
|
} catch (error) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
|
"build:vite": "vite build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"dev:vite": "vite",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -28,9 +31,11 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vite": "^8.2.1",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,41 +3,56 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
import { getSsoProvider } from '@/lib/sso-providers';
|
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||||
|
|
||||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||||
const params = useParams<{ provider: string }>();
|
const params = useParams<{ provider: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||||
const provider = getSsoProvider(providerId);
|
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||||
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
const [providerName, setProviderName] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentProvider = provider;
|
|
||||||
|
|
||||||
if (!currentProvider) {
|
|
||||||
setError('Unknown SSO provider.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentProvider.enabled) {
|
|
||||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeProvider = currentProvider;
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
async function redirectToProvider(): Promise<void> {
|
||||||
const result = await signIn.oauth2({
|
try {
|
||||||
providerId: activeProvider.id,
|
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||||
callbackURL,
|
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||||
});
|
if (cancelled) return;
|
||||||
|
|
||||||
if (!cancelled && result?.error) {
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
if (!provider) {
|
||||||
|
setError('Unknown SSO provider.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProviderName(provider.name);
|
||||||
|
if (!provider.configured) {
|
||||||
|
setError(`${provider.name} is not enabled in this deployment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (provider.loginMode !== 'oidc') {
|
||||||
|
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signIn.oauth2({
|
||||||
|
providerId: provider.id,
|
||||||
|
callbackURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelled && result?.error) {
|
||||||
|
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||||
|
}
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,19 +61,22 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [callbackURL, provider]);
|
}, [providerId, requestedCallbackURL]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
{provider
|
{providerName
|
||||||
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
? `Redirecting you to ${providerName}...`
|
||||||
: 'Preparing your sign-in request...'}
|
: 'Preparing your sign-in request...'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||||
|
>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
describe('api', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
api<{ ok: boolean }>('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: 'Mosaic' },
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/projects',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name: 'Mosaic' }),
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws the gateway JSON error with its statusCode', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
||||||
|
name: 'Error',
|
||||||
|
message: 'Forbidden',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
|
||||||
|
|
||||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
}
|
}
|
||||||
@@ -25,7 +23,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
|||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
const res = await fetch(path, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...rest,
|
...rest,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
describe('auth client origin contract', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ session: null, user: null }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const { authClient } = await import('./auth-client');
|
||||||
|
await authClient.getSession();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const firstCall = fetchMock.mock.calls.at(0);
|
||||||
|
expect(firstCall).toBeDefined();
|
||||||
|
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
||||||
|
expect(requestURL.origin).toBe(window.location.origin);
|
||||||
|
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
|
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
||||||
|
// every browser request on the current origin in development and production.
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
|
||||||
plugins: [adminClient(), genericOAuthClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveAuthCallbackURL } from './auth-redirect';
|
||||||
|
|
||||||
|
const CURRENT_ORIGIN = 'https://mosaic.example';
|
||||||
|
|
||||||
|
describe('resolveAuthCallbackURL', () => {
|
||||||
|
it('preserves a canonical same-origin path with search and hash', () => {
|
||||||
|
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
||||||
|
'/projects?view=active#current',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
'chat',
|
||||||
|
'//evil.example',
|
||||||
|
'/..//evil.com',
|
||||||
|
'/..//evil.com/x',
|
||||||
|
'/./..//evil.com',
|
||||||
|
'/../..//evil.com',
|
||||||
|
'/foo/..//evil.com',
|
||||||
|
'/\\evil.example',
|
||||||
|
'/\n//evil.example',
|
||||||
|
'/\r//evil.example',
|
||||||
|
'/\t//evil.example',
|
||||||
|
'https://evil.example/phish',
|
||||||
|
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
||||||
|
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a canonical same-origin path for post-auth navigation.
|
||||||
|
*
|
||||||
|
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
||||||
|
* variants, and control characters that the WHATWG parser normalizes away.
|
||||||
|
*/
|
||||||
|
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
||||||
|
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expectedOrigin = new URL(currentOrigin).origin;
|
||||||
|
const resolved = new URL(candidate, expectedOrigin);
|
||||||
|
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
||||||
|
return DEFAULT_AUTH_CALLBACK_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_AUTH_CALLBACK_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { ioMock } = vi.hoisted(() => ({
|
||||||
|
ioMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('socket.io-client', () => ({
|
||||||
|
io: ioMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { destroySocket, getSocket } from './socket';
|
||||||
|
|
||||||
|
describe('chat socket', () => {
|
||||||
|
let disconnectHandler: (() => void) | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
disconnectHandler = undefined;
|
||||||
|
ioMock.mockReset();
|
||||||
|
|
||||||
|
const mockSocket = {
|
||||||
|
on: vi.fn((event: string, handler: () => void) => {
|
||||||
|
if (event === 'disconnect') disconnectHandler = handler;
|
||||||
|
return mockSocket;
|
||||||
|
}),
|
||||||
|
offAny: vi.fn(() => mockSocket),
|
||||||
|
disconnect: vi.fn(() => mockSocket),
|
||||||
|
};
|
||||||
|
|
||||||
|
ioMock.mockReturnValue(mockSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
destroySocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates one same-origin /chat namespace socket until it disconnects', () => {
|
||||||
|
const first = getSocket();
|
||||||
|
const second = getSocket();
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(ioMock).toHaveBeenCalledOnce();
|
||||||
|
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
||||||
|
withCredentials: true,
|
||||||
|
autoConnect: false,
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
});
|
||||||
|
|
||||||
|
disconnectHandler?.();
|
||||||
|
getSocket();
|
||||||
|
expect(ioMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
|
||||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
|
||||||
|
|
||||||
let socket: Socket | null = null;
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
export function getSocket(): Socket {
|
export function getSocket(): Socket {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
socket = io(`${GATEWAY_URL}/chat`, {
|
socket = io('/chat', {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
|
||||||
|
|
||||||
describe('sso-providers', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the enabled providers in login button order', () => {
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
|
||||||
|
|
||||||
expect(getEnabledSsoProviders()).toEqual([
|
|
||||||
{
|
|
||||||
id: 'workos',
|
|
||||||
buttonLabel: 'Continue with WorkOS',
|
|
||||||
description: 'Enterprise SSO via WorkOS',
|
|
||||||
enabled: true,
|
|
||||||
href: '/auth/provider/workos',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
enabled: true,
|
|
||||||
href: '/auth/provider/keycloak',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
|
||||||
|
|
||||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
|
||||||
expect(getSsoProvider('keycloak')).toEqual({
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
enabled: false,
|
|
||||||
href: '/auth/provider/keycloak',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for unknown providers', () => {
|
|
||||||
expect(getSsoProvider('authentik')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
export type SsoProviderId = 'workos' | 'keycloak';
|
|
||||||
|
|
||||||
export interface SsoProvider {
|
|
||||||
id: SsoProviderId;
|
|
||||||
buttonLabel: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
|
||||||
workos: {
|
|
||||||
id: 'workos',
|
|
||||||
buttonLabel: 'Continue with WorkOS',
|
|
||||||
description: 'Enterprise SSO via WorkOS',
|
|
||||||
},
|
|
||||||
keycloak: {
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
|
||||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
|
||||||
.map((providerId) => getSsoProvider(providerId))
|
|
||||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
|
||||||
if (!isSsoProviderId(providerId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...PROVIDER_METADATA[providerId],
|
|
||||||
enabled: isSsoProviderEnabled(providerId),
|
|
||||||
href: `/auth/provider/${providerId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
|
||||||
return value === 'workos' || value === 'keycloak';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
|
||||||
switch (providerId) {
|
|
||||||
case 'workos':
|
|
||||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
|
||||||
case 'keycloak':
|
|
||||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from '@/providers/theme-provider';
|
||||||
|
import { createAppRouter } from '@/routes';
|
||||||
|
import '@/app/globals.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('missing #root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<RouterProvider router={createAppRouter()} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
|
||||||
|
import { LoginPage } from '@/spa/pages/login';
|
||||||
|
import { RegisterPage } from '@/spa/pages/register';
|
||||||
|
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
||||||
|
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
|
||||||
|
function GuestLayout(): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-surface-bg px-4 py-8">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
element: <GuestGuard />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
element: <GuestLayout />,
|
||||||
|
children: [
|
||||||
|
{ path: '/login', element: <LoginPage /> },
|
||||||
|
{ path: '/register', element: <RegisterPage /> },
|
||||||
|
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <AuthGuard />,
|
||||||
|
children: [
|
||||||
|
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||||
|
{ path: '/chat', element: <Placeholder title="Chat" /> },
|
||||||
|
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
||||||
|
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
||||||
|
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
||||||
|
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||||
|
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
||||||
|
return createBrowserRouter(routes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { useSessionMock } = vi.hoisted(() => ({
|
||||||
|
useSessionMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-client', () => ({
|
||||||
|
useSession: useSessionMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AuthGuard, GuestGuard } from './guards';
|
||||||
|
|
||||||
|
interface RenderedRouter {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
router: ReturnType<typeof createMemoryRouter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountedRoots: Root[] = [];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderRouter(
|
||||||
|
routeObjects: RouteObject[],
|
||||||
|
initialEntry: string,
|
||||||
|
): Promise<RenderedRouter> {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
||||||
|
const root = createRoot(container);
|
||||||
|
mountedRoots.push(root);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<RouterProvider router={router} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container, router };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const root of mountedRoots.splice(0)) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.body.replaceChildren();
|
||||||
|
useSessionMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const guestRoutes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <GuestGuard />,
|
||||||
|
children: [{ index: true, element: <p>Guest page</p> }],
|
||||||
|
},
|
||||||
|
{ path: '/chat', element: <p>Chat page</p> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const authenticatedRoutes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/chat',
|
||||||
|
element: <AuthGuard />,
|
||||||
|
children: [{ index: true, element: <p>Private page</p> }],
|
||||||
|
},
|
||||||
|
{ path: '/login', element: <p>Login page</p> },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('GuestGuard', () => {
|
||||||
|
it('renders the guest outlet while session lookup is pending', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||||
|
|
||||||
|
const view = await renderRouter(guestRoutes, '/login');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Guest page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the guest outlet when no session exists', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(guestRoutes, '/login');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Guest page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects an authenticated session to chat', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(guestRoutes, '/login');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Chat page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthGuard', () => {
|
||||||
|
it('renders the existing loading treatment while session lookup is pending', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||||
|
|
||||||
|
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Loading...');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects an unauthenticated visitor to login', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Login page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the authenticated outlet when a session exists', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Private page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { useSession } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
export function GuestGuard(): ReactElement {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard(): ReactElement {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-sm text-text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { authClient, signIn } from '@/lib/auth-client';
|
||||||
|
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||||
|
|
||||||
|
export function LoginPage(): ReactElement {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||||
|
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||||
|
SsoProviderDiscovery['id'] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
||||||
|
(providers) => {
|
||||||
|
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (active) setSsoProviders([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const email = String(form.get('email') ?? '');
|
||||||
|
const password = String(form.get('password') ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signIn.email({ email, password });
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? 'Sign in failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/chat', { replace: true });
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setSsoLoadingProviderId(providerId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authClient.signIn.oauth2({
|
||||||
|
providerId,
|
||||||
|
callbackURL: '/chat',
|
||||||
|
newUserCallbackURL: '/chat',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
||||||
|
setSsoLoadingProviderId(null);
|
||||||
|
}
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
||||||
|
setSsoLoadingProviderId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<SsoProviderButtons
|
||||||
|
providers={ssoProviders}
|
||||||
|
loadingProviderId={ssoLoadingProviderId}
|
||||||
|
onOidcSignIn={(providerId) => {
|
||||||
|
void handleSsoSignIn(providerId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-text-muted">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState, type FormEvent, type ReactElement } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { signUp } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
export function RegisterPage(): ReactElement {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const name = String(form.get('name') ?? '');
|
||||||
|
const email = String(form.get('email') ?? '');
|
||||||
|
const password = String(form.get('password') ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signUp.email({ name, email, password });
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? 'Registration failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/chat', { replace: true });
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-text-muted">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
||||||
|
apiMock: vi.fn(),
|
||||||
|
oauth2Mock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-client', () => ({
|
||||||
|
signIn: { oauth2: oauth2Mock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SsoCallbackPage } from './sso-callback';
|
||||||
|
|
||||||
|
const mountedRoots: Root[] = [];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
apiMock.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'authentik',
|
||||||
|
name: 'Authentik',
|
||||||
|
protocols: ['oidc'],
|
||||||
|
configured: true,
|
||||||
|
loginMode: 'oidc',
|
||||||
|
callbackPath: '/api/auth/oauth2/callback/authentik',
|
||||||
|
teamSync: { enabled: false, claim: null },
|
||||||
|
samlFallback: { configured: false, loginUrl: null },
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const root of mountedRoots.splice(0)) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.body.replaceChildren();
|
||||||
|
apiMock.mockReset();
|
||||||
|
oauth2Mock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SsoCallbackPage', () => {
|
||||||
|
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
||||||
|
const router = createMemoryRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: '/auth/provider/:provider',
|
||||||
|
element: <SsoCallbackPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mountedRoots.push(root);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<RouterProvider router={router} />);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(oauth2Mock).toHaveBeenCalledWith({
|
||||||
|
providerId: 'authentik',
|
||||||
|
callbackURL: '/chat',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useState, type ReactElement } from 'react';
|
||||||
|
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||||
|
import { signIn } from '@/lib/auth-client';
|
||||||
|
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||||
|
|
||||||
|
export function SsoCallbackPage(): ReactElement {
|
||||||
|
const { provider: providerId = '' } = useParams<'provider'>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||||
|
const [providerName, setProviderName] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function redirectToProvider(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||||
|
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||||
|
if (!provider) {
|
||||||
|
setError('Unknown SSO provider.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProviderName(provider.name);
|
||||||
|
if (!provider.configured) {
|
||||||
|
setError(`${provider.name} is not enabled in this deployment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (provider.loginMode !== 'oidc') {
|
||||||
|
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signIn.oauth2({
|
||||||
|
providerId: provider.id,
|
||||||
|
callbackURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelled && result?.error) {
|
||||||
|
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||||
|
}
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void redirectToProvider();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [providerId, requestedCallbackURL]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
|
{providerName
|
||||||
|
? `Redirecting you to ${providerName}...`
|
||||||
|
: 'Preparing your sign-in request...'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||||
|
>
|
||||||
|
<p>{error}</p>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Return to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||||
|
If the redirect does not start automatically, return to the login page and try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
|
export function Placeholder({ title }: { title: string }): ReactElement {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center">
|
||||||
|
<h1 className="text-xl font-medium">{title}</h1>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { isValidElement } from 'react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { RouteObject } from 'react-router-dom';
|
||||||
|
import { routes } from '@/routes';
|
||||||
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
|
||||||
|
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||||
|
return routeObjects.flatMap((route) => [
|
||||||
|
...(route.path ? [route.path] : []),
|
||||||
|
...(route.children ? collectPaths(route.children) : []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRoute(routeObjects: RouteObject[], path: string): RouteObject | undefined {
|
||||||
|
for (const route of routeObjects) {
|
||||||
|
if (route.path === path) return route;
|
||||||
|
const nested = route.children ? findRoute(route.children, path) : undefined;
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SPA route table', () => {
|
||||||
|
it('covers every v1 parity route from the Phase P RFC', () => {
|
||||||
|
expect(collectPaths(routes).sort()).toEqual(
|
||||||
|
[
|
||||||
|
'/',
|
||||||
|
'/admin',
|
||||||
|
'/auth/provider/:provider',
|
||||||
|
'/chat',
|
||||||
|
'/login',
|
||||||
|
'/projects',
|
||||||
|
'/projects/:id',
|
||||||
|
'/register',
|
||||||
|
'/settings',
|
||||||
|
'/tasks',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates guest and authenticated route groups', () => {
|
||||||
|
const guestPaths = collectPaths(routes.at(0)?.children ?? []);
|
||||||
|
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||||
|
expect(guestPaths).toContain('/login');
|
||||||
|
expect(guestPaths).not.toContain('/chat');
|
||||||
|
expect(authPaths).toContain('/chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['/login', '/register', '/auth/provider/:provider'])(
|
||||||
|
'renders a real guest page instead of the P1 placeholder at %s',
|
||||||
|
(path) => {
|
||||||
|
const element = findRoute(routes, path)?.element;
|
||||||
|
expect(isValidElement(element)).toBe(true);
|
||||||
|
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
||||||
|
expect(element.type).not.toBe(Placeholder);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('Vitest abort-controller realm', () => {
|
||||||
|
it('provides a global signal accepted by Node native Request', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const request = new Request('https://mosaic.invalid/navigation', {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
controller.abort();
|
||||||
|
expect(request.signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { transferableAbortController } from 'node:util';
|
||||||
|
|
||||||
|
// jsdom installs realm-local abort constructors while Node's undici Request
|
||||||
|
// remains native. React Router passes a global AbortSignal to Request, so both
|
||||||
|
// constructors must come from Node's native realm during tests.
|
||||||
|
const nativeController = transferableAbortController();
|
||||||
|
const nativeAbortController = nativeController.constructor;
|
||||||
|
const nativeAbortSignal = nativeController.signal.constructor;
|
||||||
|
|
||||||
|
for (const target of [globalThis, window]) {
|
||||||
|
Object.defineProperties(target, {
|
||||||
|
AbortController: {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: nativeAbortController,
|
||||||
|
},
|
||||||
|
AbortSignal: {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: nativeAbortSignal,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
|
||||||
|
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
|
||||||
|
// request uses a relative path, so no origin may ever be configured here or in src/.
|
||||||
|
const gatewayTarget = 'http://localhost:14242';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3100,
|
||||||
|
proxy: {
|
||||||
|
'/api': gatewayTarget,
|
||||||
|
'/socket.io': { target: gatewayTarget, ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||||
|
esbuild: {
|
||||||
|
jsx: 'automatic',
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
isolate: true,
|
||||||
exclude: ['e2e/**', 'node_modules/**'],
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
||||||
**Phase:** Execution (workstream W1 in planning-complete state)
|
**Phase:** Execution (workstream W1 in planning-complete state)
|
||||||
**Current Workstream:** W1 — Federation v1
|
**Current Workstream:** W1 — Federation v1
|
||||||
**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined)
|
**Progress:** 0 / 3 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||||
**Status:** active (continuous since 2026-03-13)
|
**Status:** active (continuous since 2026-03-13)
|
||||||
**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468)
|
**Last Updated:** 2026-07-14 (W3 Native Kanban/SOT canon independently approved under issue #751)
|
||||||
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
||||||
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
||||||
|
|
||||||
@@ -67,11 +67,12 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
|||||||
|
|
||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
| # | ID | Name | Status | Manifest | Notes |
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
| --- | ---- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
||||||
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
| W3 | KBN | Native Kanban and canonical task SOT | planning-complete | [docs/native-kanban-sot/MISSION-MANIFEST.md](./native-kanban-sot/MISSION-MANIFEST.md) | P0–P3; issue #751; implementation held until canon merge |
|
||||||
|
| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||||
|
|
||||||
### Likely Additional Workstreams (Not Yet Declared)
|
### Likely Additional Workstreams (Not Yet Declared)
|
||||||
|
|
||||||
|
|||||||
+2
-8
@@ -149,15 +149,9 @@ for any `<Image>` components added in the future.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to Apply
|
## Held future procedure
|
||||||
|
|
||||||
```bash
|
This report is non-operative evidence, not a current runbook. Until **KBN-101-00, KBN-101-03, and KBN-101-05** land, do not execute a PostgreSQL runner from this checkout. The approved future procedure is exactly: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Deployment will supply the reviewed runner, migration-only credentials, and TLS material; Gateway startup only verifies readiness.
|
||||||
# Run the DB migration (requires a live DB)
|
|
||||||
pnpm --filter @mosaicstack/db exec drizzle-kit migrate
|
|
||||||
|
|
||||||
# Or, in Docker/Swarm — migrations run automatically on gateway startup
|
|
||||||
# via runMigrations() in packages/db/src/migrate.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user