Compare commits
151
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a45f53071a | ||
|
|
8c27024d0e | ||
|
|
406e40584d | ||
|
|
677aeb0c93 | ||
|
|
bd0ef2ab25 | ||
|
|
2d5a8c81ec | ||
|
|
884d527cc8 | ||
|
|
87daa12976 | ||
|
|
b82a51da80 | ||
|
|
90cf286a09 | ||
|
|
0aef432052 | ||
|
|
41a16cc916 | ||
|
|
e16c08aa9f | ||
|
|
a34e92cf39 | ||
|
|
a4861c221f | ||
|
|
46d68e1ff4 | ||
|
|
c3496334a5 | ||
|
|
6f29d00149 | ||
|
|
068d0f9b1c | ||
|
|
13cd673d50 | ||
|
|
620cc608e0 | ||
|
|
91e692e3e7 | ||
|
|
b4753a75cd | ||
|
|
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 | ||
|
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
import { InteractionController } from '../../agent/interaction.controller.js';
|
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||||
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
||||||
import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js';
|
import { DurableSessionService } from '../../agent/durable-session.service.js';
|
||||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||||
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ function authorization(): CommandAuthorizationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Tess Discord/CLI durable-session integration', () => {
|
describe('interaction Discord/CLI durable-session integration', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
const value = priorEnv.get(key);
|
const value = priorEnv.get(key);
|
||||||
@@ -72,6 +72,7 @@ describe('Tess Discord/CLI durable-session integration', () => {
|
|||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
{
|
{
|
||||||
instanceId: 'Nova',
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
guildId: 'guild-1',
|
guildId: 'guild-1',
|
||||||
channelId: 'channel-1',
|
channelId: 'channel-1',
|
||||||
pairedUsers: {
|
pairedUsers: {
|
||||||
@@ -80,7 +81,7 @@ describe('Tess Discord/CLI durable-session integration', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const durable = new TessDurableSessionService(
|
const durable = new DurableSessionService(
|
||||||
new InMemoryDurableSessionStore() as never,
|
new InMemoryDurableSessionStore() as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
@@ -133,6 +134,7 @@ describe('Tess Discord/CLI durable-session integration', () => {
|
|||||||
interactionBindings: [
|
interactionBindings: [
|
||||||
{
|
{
|
||||||
instanceId: 'Nova',
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
guildId: 'guild-1',
|
guildId: 'guild-1',
|
||||||
channelId: 'channel-1',
|
channelId: 'channel-1',
|
||||||
pairedUsers: {
|
pairedUsers: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -115,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,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ 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 { InteractionController } from './interaction.controller.js';
|
||||||
import { RoutingController } from './routing/routing.controller.js';
|
import { RoutingController } from './routing/routing.controller.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.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';
|
||||||
@@ -21,6 +21,12 @@ import { LogModule } from '../log/log.module.js';
|
|||||||
import { CommandsModule } from '../commands/commands.module.js';
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||||
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.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,
|
||||||
RUNTIME_APPROVAL_VERIFIER,
|
RUNTIME_APPROVAL_VERIFIER,
|
||||||
@@ -44,8 +50,15 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
TessDurableSessionRepository,
|
DurableSessionRepository,
|
||||||
TessDurableSessionService,
|
DurableSessionService,
|
||||||
|
ConnectorLeaseRepository,
|
||||||
|
DenyConnectorLeasePolicy,
|
||||||
|
{
|
||||||
|
provide: CONNECTOR_LEASE_POLICY,
|
||||||
|
useExisting: DenyConnectorLeasePolicy,
|
||||||
|
},
|
||||||
|
ConnectorLeaseService,
|
||||||
{
|
{
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
useFactory: createGatewayRuntimeProviderRegistry,
|
useFactory: createGatewayRuntimeProviderRegistry,
|
||||||
@@ -76,8 +89,9 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
TessDurableSessionService,
|
DurableSessionService,
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
|
ConnectorLeaseService,
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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 { ChannelAttachmentDto } from '@mosaicstack/types';
|
||||||
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
|
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { MEMORY } from '../memory/memory.tokens.js';
|
import { MEMORY } from '../memory/memory.tokens.js';
|
||||||
@@ -43,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 {
|
||||||
@@ -428,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));
|
||||||
@@ -487,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;
|
||||||
@@ -673,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}`);
|
||||||
@@ -681,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];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
/** Server-side request for a replay-safe provider message. */
|
/** Server-side request for a replay-safe provider message. */
|
||||||
export interface TessProviderOutboxDto {
|
export interface ProviderOutboxDto {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
+16
-18
@@ -6,8 +6,8 @@ import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/
|
|||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
|
|
||||||
const IDENTITY: DurableSessionIdentity = {
|
const IDENTITY: DurableSessionIdentity = {
|
||||||
agentName: 'Nova',
|
agentName: 'Nova',
|
||||||
@@ -18,7 +18,7 @@ const IDENTITY: DurableSessionIdentity = {
|
|||||||
runtimeSessionId: 'nova',
|
runtimeSessionId: 'nova',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('TessDurableSessionRepository', () => {
|
describe('DurableSessionRepository', () => {
|
||||||
let dataDir: string | undefined;
|
let dataDir: string | undefined;
|
||||||
let handle: DbHandle;
|
let handle: DbHandle;
|
||||||
let previousAuthSecret: string | undefined;
|
let previousAuthSecret: string | undefined;
|
||||||
@@ -48,9 +48,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
||||||
const beforeRestart = new DurableSessionCoordinator(
|
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
new TessDurableSessionRepository(handle.db),
|
|
||||||
);
|
|
||||||
await beforeRestart.create(IDENTITY);
|
await beforeRestart.create(IDENTITY);
|
||||||
await beforeRestart.receive({
|
await beforeRestart.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -92,7 +90,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
await handle.close();
|
await handle.close();
|
||||||
handle = createPgliteDb(dataDir!);
|
handle = createPgliteDb(dataDir!);
|
||||||
|
|
||||||
const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||||
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
||||||
const handled: string[] = [];
|
const handled: string[] = [];
|
||||||
@@ -120,7 +118,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('redacts sensitive durable payloads before persistence', async () => {
|
it('redacts sensitive durable payloads before persistence', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
await coordinator.receive({
|
await coordinator.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -157,7 +155,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
delete process.env['BETTER_AUTH_SECRET'];
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
@@ -177,7 +175,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -227,7 +225,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
const inbox = {
|
const inbox = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -255,7 +253,7 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
await coordinator.receive({
|
await coordinator.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -293,10 +291,10 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
||||||
const repository = new TessDurableSessionRepository(handle.db);
|
const repository = new DurableSessionRepository(handle.db);
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'live-effect',
|
idempotencyKey: 'live-effect',
|
||||||
@@ -333,10 +331,10 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
||||||
const repository = new TessDurableSessionRepository(handle.db);
|
const repository = new DurableSessionRepository(handle.db);
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'mismatch-effect',
|
idempotencyKey: 'mismatch-effect',
|
||||||
@@ -366,10 +364,10 @@ describe('TessDurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
||||||
const repository = new TessDurableSessionRepository(handle.db);
|
const repository = new DurableSessionRepository(handle.db);
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
const service = new DurableSessionService(repository, runtimeProviders as never);
|
||||||
const first = {
|
const first = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'scoped-effect-one',
|
idempotencyKey: 'scoped-effect-one',
|
||||||
+9
-10
@@ -33,7 +33,7 @@ import type {
|
|||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TessDurableSessionRepository implements DurableSessionStore {
|
export class DurableSessionRepository implements DurableSessionStore {
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||||
@@ -51,7 +51,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
|
|
||||||
const existing = await this.session(identity.sessionId);
|
const existing = await this.session(identity.sessionId);
|
||||||
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
||||||
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
||||||
}
|
}
|
||||||
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
||||||
// the conversation handle and owner scope remain immutable.
|
// the conversation handle and owner scope remain immutable.
|
||||||
@@ -135,13 +135,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`);
|
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
const entry = toInbox(existing[0]);
|
const entry = toInbox(existing[0]);
|
||||||
if (
|
if (
|
||||||
!sameInbox(entry, record) ||
|
!sameInbox(entry, record) ||
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: entry.status };
|
return { accepted: false, status: entry.status };
|
||||||
}
|
}
|
||||||
@@ -224,14 +224,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing[0])
|
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`);
|
|
||||||
const entry = toOutbox(existing[0]);
|
const entry = toOutbox(existing[0]);
|
||||||
if (
|
if (
|
||||||
!sameOutbox(entry, record) ||
|
!sameOutbox(entry, record) ||
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: entry.status };
|
return { accepted: false, status: entry.status };
|
||||||
}
|
}
|
||||||
@@ -338,7 +337,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
||||||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +359,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||||
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
||||||
if (!checkpoint) {
|
if (!checkpoint) {
|
||||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
const inserted = await this.db
|
const inserted = await this.db
|
||||||
.insert(interactionHandoffs)
|
.insert(interactionHandoffs)
|
||||||
@@ -371,7 +370,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
|
|||||||
|
|
||||||
const existing = await this.findHandoff(input.handoffId);
|
const existing = await this.findHandoff(input.handoffId);
|
||||||
if (!existing || !sameHandoff(existing, input)) {
|
if (!existing || !sameHandoff(existing, input)) {
|
||||||
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+14
-14
@@ -1,23 +1,23 @@
|
|||||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
import type { ProviderOutboxDto } from './durable-session.dto.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
import {
|
import {
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
type RuntimeProviderRequestContext,
|
type RuntimeProviderRequestContext,
|
||||||
} from './runtime-provider-registry.service.js';
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scoped gateway boundary for the canonical Tess state machine. It deliberately
|
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
|
||||||
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
||||||
* MCP adapters without a server-derived actor/tenant/correlation context.
|
* MCP adapters without a server-derived actor/tenant/correlation context.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TessDurableSessionService {
|
export class DurableSessionService {
|
||||||
private readonly coordinator: DurableSessionCoordinator;
|
private readonly coordinator: DurableSessionCoordinator;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
|
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
|
||||||
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
||||||
) {
|
) {
|
||||||
this.coordinator = new DurableSessionCoordinator(repository);
|
this.coordinator = new DurableSessionCoordinator(repository);
|
||||||
@@ -32,12 +32,12 @@ export class TessDurableSessionService {
|
|||||||
identity.ownerId !== context.actorScope.userId ||
|
identity.ownerId !== context.actorScope.userId ||
|
||||||
identity.tenantId !== context.actorScope.tenantId
|
identity.tenantId !== context.actorScope.tenantId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Durable Tess enrollment scope mismatch');
|
throw new ForbiddenException('Durable session enrollment scope mismatch');
|
||||||
}
|
}
|
||||||
await this.coordinator.create(identity);
|
await this.coordinator.create(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> {
|
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
|
||||||
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
await this.coordinator.enqueueOutbox({
|
await this.coordinator.enqueueOutbox({
|
||||||
@@ -50,9 +50,9 @@ export class TessDurableSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||||
if (sessionId !== input.sessionId) {
|
if (sessionId !== input.sessionId) {
|
||||||
throw new ForbiddenException('Durable Tess outbox session mismatch');
|
throw new ForbiddenException('Durable outbox session mismatch');
|
||||||
}
|
}
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
@@ -92,7 +92,7 @@ export class TessDurableSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||||
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
await this.coordinator.recover(sessionId);
|
await this.coordinator.recover(sessionId);
|
||||||
@@ -100,24 +100,24 @@ export class TessDurableSessionService {
|
|||||||
|
|
||||||
private assertOutboxScope(
|
private assertOutboxScope(
|
||||||
entry: { kind: string; correlationId: string; channelId: string },
|
entry: { kind: string; correlationId: string; channelId: string },
|
||||||
input: TessProviderOutboxDto,
|
input: ProviderOutboxDto,
|
||||||
): void {
|
): void {
|
||||||
if (
|
if (
|
||||||
entry.kind !== 'provider.send' ||
|
entry.kind !== 'provider.send' ||
|
||||||
entry.correlationId !== input.correlationId ||
|
entry.correlationId !== input.correlationId ||
|
||||||
entry.channelId !== input.context.channelId
|
entry.channelId !== input.context.channelId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
|
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void {
|
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
|
||||||
if (
|
if (
|
||||||
input.context.actorScope.userId !== ownerId ||
|
input.context.actorScope.userId !== ownerId ||
|
||||||
input.context.actorScope.tenantId !== tenantId ||
|
input.context.actorScope.tenantId !== tenantId ||
|
||||||
input.context.correlationId !== input.correlationId
|
input.context.correlationId !== input.correlationId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
|
throw new ForbiddenException('Durable session scope or correlation mismatch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,8 @@ import {
|
|||||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
RuntimeProviderAuditService,
|
RuntimeProviderAuditService,
|
||||||
} from './runtime-provider-registry.service.js';
|
} from './runtime-provider-registry.service.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
import { DurableSessionRepository } from './durable-session.repository.js';
|
||||||
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';
|
||||||
@@ -88,9 +88,9 @@ describe('Hermes runtime provider reachability', (): void => {
|
|||||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
||||||
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
||||||
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
||||||
.overrideProvider(TessDurableSessionService)
|
.overrideProvider(DurableSessionService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(TessDurableSessionRepository)
|
.overrideProvider(DurableSessionRepository)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
.overrideProvider(AgentService)
|
.overrideProvider(AgentService)
|
||||||
.useValue({})
|
.useValue({})
|
||||||
@@ -115,6 +115,38 @@ describe('Hermes runtime provider reachability', (): void => {
|
|||||||
await app?.close();
|
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> => {
|
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
||||||
if (!app) throw new Error('Nest application did not initialize');
|
if (!app) throw new Error('Nest application did not initialize');
|
||||||
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Observable } from 'rxjs';
|
|||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
import { DurableSessionService } from './durable-session.service.js';
|
||||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
import {
|
import {
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
export class InteractionController {
|
export class InteractionController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||||
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
|
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('sessions')
|
@Get('sessions')
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import * as nodeOs from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import * as nodeUrl from 'node:url';
|
||||||
|
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
|
||||||
|
interface ComposedModuleGraph {
|
||||||
|
imports: readonly unknown[];
|
||||||
|
federationModule: unknown;
|
||||||
|
bootLogLines: readonly string[];
|
||||||
|
mosaicConfig: MosaicConfig;
|
||||||
|
resolvedConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageTier = 'local' | 'standalone' | 'federated';
|
||||||
|
|
||||||
|
interface ModuleGraphFixture {
|
||||||
|
tempRoot: string;
|
||||||
|
anchor: string;
|
||||||
|
homePath: string;
|
||||||
|
cwdPath: string;
|
||||||
|
monorepoRootEnvPath: string;
|
||||||
|
gatewayLocalEnvPath: string;
|
||||||
|
daemonEnvPath: string;
|
||||||
|
monorepoRootConfigPath: string;
|
||||||
|
gatewayLocalConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModuleGraphFixtureOptions {
|
||||||
|
rootEnvMode?: 'present' | 'absent';
|
||||||
|
rootTier?: StorageTier;
|
||||||
|
rootEnvContents?: string;
|
||||||
|
redactionMarker?: string;
|
||||||
|
gatewayLocalTier?: StorageTier;
|
||||||
|
gatewayLocalEnvContents?: string;
|
||||||
|
daemonEnvContents?: string;
|
||||||
|
inheritedTier?: StorageTier;
|
||||||
|
expectedProcessTier?: string;
|
||||||
|
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||||
|
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||||
|
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
||||||
|
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
||||||
|
|
||||||
|
function configJson(tier: StorageTier): string {
|
||||||
|
if (tier === 'local') {
|
||||||
|
return JSON.stringify({
|
||||||
|
tier,
|
||||||
|
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||||
|
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
tier,
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||||
|
return { ...process.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in snapshot)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(snapshot)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||||
|
const relativePath = relative(tempRoot, path);
|
||||||
|
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigModuleProvider {
|
||||||
|
provide: string;
|
||||||
|
useFactory: () => MosaicConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('provide' in value) || typeof value.provide !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'useFactory' in value && typeof value.useFactory === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
||||||
|
expect(bootLogLines).toHaveLength(1);
|
||||||
|
const [bootLogLine] = bootLogLines;
|
||||||
|
if (bootLogLine === undefined) {
|
||||||
|
throw new Error('Expected a single boot log line');
|
||||||
|
}
|
||||||
|
|
||||||
|
return bootLogLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBootLogLine(
|
||||||
|
bootLogLines: readonly string[],
|
||||||
|
tier: StorageTier,
|
||||||
|
source: string,
|
||||||
|
): void {
|
||||||
|
const bootLogLine = singleBootLogLine(bootLogLines);
|
||||||
|
|
||||||
|
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
||||||
|
expect(bootLogLine).toContain(`source=${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModuleGraphFromDotenv(
|
||||||
|
options: ModuleGraphFixtureOptions,
|
||||||
|
): Promise<ComposedModuleGraph> {
|
||||||
|
const originalEnv = snapshotProcessEnv();
|
||||||
|
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
|
||||||
|
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||||
|
const homePath = join(tempRoot, 'home');
|
||||||
|
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
|
||||||
|
const fixture: ModuleGraphFixture = {
|
||||||
|
tempRoot,
|
||||||
|
anchor,
|
||||||
|
homePath,
|
||||||
|
cwdPath,
|
||||||
|
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
|
||||||
|
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
|
||||||
|
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
|
||||||
|
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
|
||||||
|
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
|
||||||
|
};
|
||||||
|
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||||
|
|
||||||
|
for (const path of Object.values(fixture)) {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(anchor, { recursive: true });
|
||||||
|
await mkdir(cwdPath, { recursive: true });
|
||||||
|
|
||||||
|
if ((options.rootEnvMode ?? 'present') === 'absent') {
|
||||||
|
if (
|
||||||
|
options.rootEnvContents !== undefined ||
|
||||||
|
options.rootTier !== undefined ||
|
||||||
|
options.redactionMarker !== undefined
|
||||||
|
) {
|
||||||
|
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
||||||
|
throw new Error('Expected rootTier or rootEnvContents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
||||||
|
const rootFixtureWithMarker = options.redactionMarker
|
||||||
|
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
|
||||||
|
: rootFixture;
|
||||||
|
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.daemonEnvContents !== undefined) {
|
||||||
|
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.gatewayLocalEnvContents !== undefined) {
|
||||||
|
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
|
||||||
|
} else if (options.gatewayLocalTier !== undefined) {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalEnvPath,
|
||||||
|
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
||||||
|
tempRoot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env['HOME'] = homePath;
|
||||||
|
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['VALKEY_URL'];
|
||||||
|
delete process.env['MOSAIC_GATEWAY_HOME'];
|
||||||
|
|
||||||
|
await options.setup?.(fixture);
|
||||||
|
|
||||||
|
if (options.inheritedTier !== undefined) {
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||||
|
vi.doMock('node:url', () => ({
|
||||||
|
...nodeUrl,
|
||||||
|
fileURLToPath: (url: string | URL): string => {
|
||||||
|
const actualPath = nodeUrl.fileURLToPath(url);
|
||||||
|
if (
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||||
|
) {
|
||||||
|
return join(anchor, 'env.ts');
|
||||||
|
}
|
||||||
|
return actualPath;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||||
|
|
||||||
|
if (options.inheritedTier === undefined) {
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envModule = await import('./env.js');
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
|
||||||
|
options.expectedProcessTier ?? options.rootTier,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { AppModule } = await import('./app.module.js');
|
||||||
|
const { FederationModule } = await import('./federation/federation.module.js');
|
||||||
|
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||||
|
|
||||||
|
if (!Array.isArray(imports)) {
|
||||||
|
throw new Error('AppModule imports metadata is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
|
||||||
|
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
|
||||||
|
|
||||||
|
if (!Array.isArray(providers)) {
|
||||||
|
throw new Error('ConfigModule providers metadata is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configProvider = providers
|
||||||
|
.filter(isConfigModuleProvider)
|
||||||
|
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
|
||||||
|
|
||||||
|
if (!configProvider) {
|
||||||
|
throw new Error('MOSAIC_CONFIG provider factory not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imports,
|
||||||
|
federationModule: FederationModule,
|
||||||
|
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
||||||
|
args.map((value: unknown): string => String(value)).join(' '),
|
||||||
|
),
|
||||||
|
mosaicConfig: configProvider.useFactory(),
|
||||||
|
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cwdSpy?.mockRestore();
|
||||||
|
vi.doUnmock('node:url');
|
||||||
|
vi.doUnmock('node:os');
|
||||||
|
vi.resetModules();
|
||||||
|
consoleInfoSpy?.mockRestore();
|
||||||
|
restoreProcessEnv(originalEnv);
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppModule federation gating', (): void => {
|
||||||
|
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
||||||
|
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
||||||
|
const envImportIndex = mainSource.indexOf("import './env.js';");
|
||||||
|
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
||||||
|
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
||||||
|
|
||||||
|
expect(envImportIndex).toBeGreaterThan(-1);
|
||||||
|
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
||||||
|
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores ambient cwd/.env and cwd/../.env files',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(
|
||||||
|
resolve(fixture.cwdPath, '..', '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores an ambient cwd/mosaic.config.json federated config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores an ambient cwd/../../mosaic.config.json federated config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
let gatewayLocalConfigPath = '';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
let daemonConfigPath = '';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
|
||||||
|
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
|
||||||
|
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalConfigPath,
|
||||||
|
configJson('standalone'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||||
|
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
|
||||||
|
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'logs mosaic.config.json when anchored config and env tiers are both federated',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'federated',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.monorepoRootConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'attributes an invalid monorepo-root dotenv tier to the default',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||||
|
expectedProcessTier: 'invalid',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||||
|
inheritedTier: 'local',
|
||||||
|
expectedProcessTier: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'daemon .env wins over monorepo-root and gateway-local tier values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
||||||
|
expectedProcessTier: 'standalone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
inheritedTier: 'standalone',
|
||||||
|
expectedProcessTier: 'standalone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
expectedProcessTier: 'federated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'monorepo-root .env wins over gateway-local tier values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'standalone',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['local', 'standalone'] as const)(
|
||||||
|
'does not register FederationModule for the %s tier',
|
||||||
|
async (tier): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const redactionMarker = 'redaction-fixture-marker';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'federated',
|
||||||
|
redactionMarker,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
tier: 'federated',
|
||||||
|
storage: {
|
||||||
|
type: 'postgres',
|
||||||
|
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
}),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('local');
|
||||||
|
expect(graph.mosaicConfig.storage).not.toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.monorepoRootConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.mosaicConfig.storage).toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -26,6 +26,16 @@ import { WorkspaceModule } from './workspace/workspace.module.js';
|
|||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { loadConfig } from '@mosaicstack/config';
|
||||||
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
|
||||||
|
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
||||||
|
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
||||||
|
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
||||||
|
// federation profile "must not start in non-federated dev"). The gateway
|
||||||
|
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
|
||||||
|
// configuration is visible here.
|
||||||
|
const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -53,7 +63,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
FederationModule,
|
...(federationEnabled ? [FederationModule] : []),
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
parseDiscordInteractionBindings,
|
parseDiscordInteractionBindings,
|
||||||
resolveDiscordInteractionActorId,
|
resolveDiscordInteractionActorId,
|
||||||
resolveDiscordInteractionBinding,
|
resolveDiscordInteractionBinding,
|
||||||
|
type DiscordAttachment,
|
||||||
type DiscordIngressEnvelope,
|
type DiscordIngressEnvelope,
|
||||||
type DiscordIngressPayload,
|
type DiscordIngressPayload,
|
||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
@@ -30,6 +31,7 @@ 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 {
|
import {
|
||||||
@@ -37,7 +39,7 @@ import {
|
|||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
type RuntimeAuditSink,
|
type RuntimeAuditSink,
|
||||||
} from '../agent/runtime-provider-registry.service.js';
|
} from '../agent/runtime-provider-registry.service.js';
|
||||||
import { TessDurableSessionService } from '../agent/tess-durable-session.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,
|
||||||
@@ -56,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. */
|
||||||
@@ -76,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;
|
||||||
@@ -88,7 +153,8 @@ 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['correlationId'],
|
||||||
payload['messageId'],
|
payload['messageId'],
|
||||||
payload['guildId'],
|
payload['guildId'],
|
||||||
@@ -96,15 +162,40 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo
|
|||||||
payload['userId'],
|
payload['userId'],
|
||||||
payload['conversationId'],
|
payload['conversationId'],
|
||||||
payload['content'],
|
payload['content'],
|
||||||
].every((field: unknown): boolean => typeof field === 'string');
|
].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))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +231,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@Inject(RuntimeProviderService)
|
@Inject(RuntimeProviderService)
|
||||||
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(TessDurableSessionService)
|
@Inject(DurableSessionService)
|
||||||
private readonly durableSessions: TessDurableSessionService | null = null,
|
private readonly durableSessions: DurableSessionService | null = null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
||||||
@@ -174,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 {
|
||||||
@@ -218,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}`);
|
||||||
@@ -227,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(
|
||||||
@@ -281,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);
|
||||||
@@ -304,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,
|
||||||
@@ -360,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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -374,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();
|
||||||
}
|
}
|
||||||
@@ -389,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: '',
|
||||||
@@ -438,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}`,
|
||||||
@@ -645,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);
|
||||||
}
|
}
|
||||||
@@ -677,16 +815,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
!this.durableSessions
|
!this.durableSessions
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const binding = resolveDiscordInteractionBinding(
|
const binding = this.discordBindingFor(ingress, 'approve');
|
||||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
|
||||||
ingress.guildId,
|
|
||||||
ingress.channelId,
|
|
||||||
ingress.userId,
|
|
||||||
'approve',
|
|
||||||
);
|
|
||||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||||
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
const agentName = binding?.instanceId;
|
||||||
if (!actorId || !agentName || binding.instanceId !== agentName) {
|
if (!actorId || !agentName) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
|
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
|
||||||
);
|
);
|
||||||
@@ -774,13 +906,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||||
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
|
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
|
||||||
return;
|
return;
|
||||||
const binding = resolveDiscordInteractionBinding(
|
const binding = this.discordBindingFor(ingress, 'stop');
|
||||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
|
||||||
ingress.guildId,
|
|
||||||
ingress.channelId,
|
|
||||||
ingress.userId,
|
|
||||||
'stop',
|
|
||||||
);
|
|
||||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||||
if (!actorId) return;
|
if (!actorId) return;
|
||||||
|
|
||||||
@@ -854,17 +980,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const binding = resolveDiscordInteractionBinding(
|
const binding = this.discordBindingFor(payload, operation);
|
||||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
|
||||||
payload.guildId,
|
|
||||||
payload.channelId,
|
|
||||||
payload.userId,
|
|
||||||
operation,
|
|
||||||
);
|
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
|
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
|
||||||
return null;
|
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 {
|
} catch {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
|
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
|
||||||
@@ -880,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(',')
|
||||||
@@ -958,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) => {
|
||||||
|
const attachments = this.persistedChannelAttachments(msg.metadata);
|
||||||
|
return {
|
||||||
role: msg.role as 'user' | 'assistant' | 'system',
|
role: msg.role as 'user' | 'assistant' | 'system',
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
createdAt: msg.createdAt,
|
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}`,
|
||||||
@@ -972,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,
|
||||||
@@ -979,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,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) {
|
||||||
@@ -1089,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 {
|
||||||
@@ -1101,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;
|
||||||
@@ -1173,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> = {
|
||||||
@@ -1225,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;
|
||||||
}
|
}
|
||||||
@@ -1250,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,
|
||||||
@@ -1267,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({
|
||||||
|
|||||||
@@ -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,6 +446,7 @@ 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}`;
|
||||||
|
if (this.redis) {
|
||||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
@@ -451,6 +455,7 @@ export class CommandExecutorService {
|
|||||||
'EX',
|
'EX',
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { 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';
|
||||||
@@ -16,13 +18,17 @@ 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,
|
||||||
@@ -38,9 +44,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
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(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { resolveGatewayConfigPath } from '../env.js';
|
||||||
|
|
||||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: MOSAIC_CONFIG,
|
provide: MOSAIC_CONFIG,
|
||||||
useFactory: (): MosaicConfig => loadConfig(),
|
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MOSAIC_CONFIG],
|
exports: [MOSAIC_CONFIG],
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
|
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 { MosCoordinationController } from './mos-coordination.controller.js';
|
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||||
import {
|
import {
|
||||||
MOS_COORDINATION_CONFIG,
|
COORDINATION_CONFIG,
|
||||||
MOS_COORDINATION_PORT,
|
COORDINATION_PORT,
|
||||||
MosCoordinationService,
|
InteractionCoordinationService,
|
||||||
} from './mos-coordination.service.js';
|
} from './interaction-coordination.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
CoordService,
|
CoordService,
|
||||||
{
|
{
|
||||||
provide: MOS_COORDINATION_PORT,
|
provide: COORDINATION_PORT,
|
||||||
useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(),
|
useFactory: (): InMemoryInteractionCoordinationPort =>
|
||||||
|
new InMemoryInteractionCoordinationPort(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MOS_COORDINATION_CONFIG,
|
provide: COORDINATION_CONFIG,
|
||||||
useFactory: () => ({
|
useFactory: () => ({
|
||||||
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
||||||
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
MosCoordinationService,
|
InteractionCoordinationService,
|
||||||
],
|
],
|
||||||
controllers: [CoordController, MosCoordinationController],
|
controllers: [CoordController, InteractionCoordinationController],
|
||||||
exports: [CoordService, MosCoordinationService],
|
exports: [CoordService, InteractionCoordinationService],
|
||||||
})
|
})
|
||||||
export class CoordModule {}
|
export class CoordModule {}
|
||||||
|
|||||||
+15
-4
@@ -1,16 +1,27 @@
|
|||||||
|
const PATH_METADATA = 'path';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { MosCoordinationController } from './mos-coordination.controller.js';
|
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||||
|
|
||||||
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
||||||
|
|
||||||
describe('MosCoordinationController', () => {
|
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 () => {
|
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
||||||
const coordination = {
|
const coordination = {
|
||||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
result: vi.fn(),
|
result: vi.fn(),
|
||||||
};
|
};
|
||||||
const controller = new MosCoordinationController(coordination as never);
|
const controller = new InteractionCoordinationController(coordination as never);
|
||||||
|
|
||||||
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
|
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
|
||||||
|
|
||||||
@@ -26,7 +37,7 @@ describe('MosCoordinationController', () => {
|
|||||||
|
|
||||||
it('requires a correlation header before invoking the coordination service', async () => {
|
it('requires a correlation header before invoking the coordination service', async () => {
|
||||||
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
||||||
const controller = new MosCoordinationController(coordination as never);
|
const controller = new InteractionCoordinationController(coordination as never);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
||||||
+16
-14
@@ -14,27 +14,29 @@ import { CurrentUser } from '../auth/current-user.decorator.js';
|
|||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
import type {
|
import type {
|
||||||
MosCoordinationObservationDto,
|
InteractionCoordinationObservationDto,
|
||||||
MosCoordinationResponseDto,
|
InteractionCoordinationResponseDto,
|
||||||
MosCoordinationResultDto,
|
InteractionCoordinationResultDto,
|
||||||
CreateMosHandoffDto,
|
CreateHandoffDto,
|
||||||
} from './mos-coordination.dto.js';
|
} from './interaction-coordination.dto.js';
|
||||||
import { MosCoordinationService } from './mos-coordination.service.js';
|
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||||
|
|
||||||
/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */
|
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
|
||||||
@Controller('api/coord/mos')
|
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
|
||||||
|
@Controller(['api/coord/interaction', 'api/coord/mos'])
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class MosCoordinationController {
|
export class InteractionCoordinationController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MosCoordinationService) private readonly coordination: MosCoordinationService,
|
@Inject(InteractionCoordinationService)
|
||||||
|
private readonly coordination: InteractionCoordinationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('handoff')
|
@Post('handoff')
|
||||||
async handoff(
|
async handoff(
|
||||||
@Body() request: CreateMosHandoffDto,
|
@Body() request: CreateHandoffDto,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
): Promise<MosCoordinationResponseDto> {
|
): Promise<InteractionCoordinationResponseDto> {
|
||||||
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ export class MosCoordinationController {
|
|||||||
@Param('handoffId') handoffId: string,
|
@Param('handoffId') handoffId: string,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
): Promise<MosCoordinationObservationDto> {
|
): Promise<InteractionCoordinationObservationDto> {
|
||||||
return {
|
return {
|
||||||
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
||||||
};
|
};
|
||||||
@@ -54,7 +56,7 @@ export class MosCoordinationController {
|
|||||||
@Param('handoffId') handoffId: string,
|
@Param('handoffId') handoffId: string,
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
@Headers('x-correlation-id') correlationId?: string,
|
||||||
): Promise<MosCoordinationResultDto> {
|
): Promise<InteractionCoordinationResultDto> {
|
||||||
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
+6
-6
@@ -1,25 +1,25 @@
|
|||||||
import type {
|
import type {
|
||||||
CoordinationObservation,
|
CoordinationObservation,
|
||||||
CoordinationResult,
|
CoordinationResult,
|
||||||
MosHandoffReceipt,
|
HandoffReceipt,
|
||||||
} from '@mosaicstack/coord';
|
} from '@mosaicstack/coord';
|
||||||
|
|
||||||
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
||||||
export interface CreateMosHandoffDto {
|
export interface CreateHandoffDto {
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
missionId?: string;
|
missionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationResponseDto {
|
export interface InteractionCoordinationResponseDto {
|
||||||
receipt: MosHandoffReceipt;
|
receipt: HandoffReceipt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationObservationDto {
|
export interface InteractionCoordinationObservationDto {
|
||||||
observation: CoordinationObservation;
|
observation: CoordinationObservation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MosCoordinationResultDto {
|
export interface InteractionCoordinationResultDto {
|
||||||
result: CoordinationResult;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+31
-31
@@ -1,15 +1,15 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
InMemoryMosCoordinationPort,
|
InMemoryInteractionCoordinationPort,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
type MosHandoff,
|
type Handoff,
|
||||||
} from '@mosaicstack/coord';
|
} from '@mosaicstack/coord';
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
import {
|
import {
|
||||||
MosCoordinationService,
|
InteractionCoordinationService,
|
||||||
type MosCoordinationConfig,
|
type InteractionCoordinationConfig,
|
||||||
type MosCoordinationGatewayError,
|
type InteractionCoordinationGatewayError,
|
||||||
} from './mos-coordination.service.js';
|
} from './interaction-coordination.service.js';
|
||||||
|
|
||||||
const context: RuntimeProviderRequestContext = {
|
const context: RuntimeProviderRequestContext = {
|
||||||
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||||
@@ -17,28 +17,28 @@ const context: RuntimeProviderRequestContext = {
|
|||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
};
|
};
|
||||||
|
|
||||||
const config: MosCoordinationConfig = {
|
const config: InteractionCoordinationConfig = {
|
||||||
interactionAgentId: 'Nova',
|
interactionAgentId: 'Nova',
|
||||||
orchestrationAgentId: 'Conductor',
|
orchestrationAgentId: 'Conductor',
|
||||||
};
|
};
|
||||||
|
|
||||||
function service(
|
function service(
|
||||||
port: MosCoordinationPort = new InMemoryMosCoordinationPort(),
|
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
|
||||||
options: {
|
options: {
|
||||||
config?: MosCoordinationConfig;
|
config?: InteractionCoordinationConfig;
|
||||||
handoffIdFactory?: () => string;
|
handoffIdFactory?: () => string;
|
||||||
} = {},
|
} = {},
|
||||||
): MosCoordinationService {
|
): InteractionCoordinationService {
|
||||||
return new MosCoordinationService(
|
return new InteractionCoordinationService(
|
||||||
port,
|
port,
|
||||||
options.config ?? config,
|
options.config ?? config,
|
||||||
options.handoffIdFactory ?? (() => 'handoff-1'),
|
options.handoffIdFactory ?? (() => 'handoff-1'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('MosCoordinationService authority boundary', (): void => {
|
describe('InteractionCoordinationService authority boundary', (): void => {
|
||||||
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const coordination = service(adapter);
|
const coordination = service(adapter);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -53,8 +53,8 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
correlationId: 'corr-1',
|
correlationId: 'corr-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
||||||
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
||||||
|
|
||||||
const followUpContext = { ...context, correlationId: 'corr-2' };
|
const followUpContext = { ...context, correlationId: 'corr-2' };
|
||||||
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
@@ -64,12 +64,12 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||||
targetAgentId: 'Conductor',
|
targetAgentId: 'Conductor',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
summary: 'Merged by Mos',
|
summary: 'Merged by orchestrator',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const handoff = vi.spyOn(adapter, 'handoff');
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
const coordination = service(adapter, {
|
const coordination = service(adapter, {
|
||||||
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
||||||
@@ -82,12 +82,12 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'unconfigured_requester',
|
code: 'unconfigured_requester',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
expect(handoff).not.toHaveBeenCalled();
|
expect(handoff).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const handoff = vi.spyOn(adapter, 'handoff');
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
const coordination = service(adapter, {
|
const coordination = service(adapter, {
|
||||||
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||||
@@ -103,7 +103,7 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const observe = vi.spyOn(adapter, 'observe');
|
const observe = vi.spyOn(adapter, 'observe');
|
||||||
const result = vi.spyOn(adapter, 'result');
|
const result = vi.spyOn(adapter, 'result');
|
||||||
const coordination = service(adapter);
|
const coordination = service(adapter);
|
||||||
@@ -118,10 +118,10 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
};
|
};
|
||||||
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
code: 'cross_tenant_forbidden',
|
code: 'cross_tenant_forbidden',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||||
code: 'cross_tenant_forbidden',
|
code: 'cross_tenant_forbidden',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
expect(observe).not.toHaveBeenCalled();
|
expect(observe).not.toHaveBeenCalled();
|
||||||
expect(result).not.toHaveBeenCalled();
|
expect(result).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -132,8 +132,8 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
const delivered = new Promise<void>((resolve: () => void): void => {
|
const delivered = new Promise<void>((resolve: () => void): void => {
|
||||||
release = resolve;
|
release = resolve;
|
||||||
});
|
});
|
||||||
const adapter: MosCoordinationPort = {
|
const adapter: InteractionCoordinationPort = {
|
||||||
handoff: vi.fn(async (handoff: MosHandoff) => {
|
handoff: vi.fn(async (handoff: Handoff) => {
|
||||||
await delivered;
|
await delivered;
|
||||||
return {
|
return {
|
||||||
handoffId: handoff.handoffId,
|
handoffId: handoff.handoffId,
|
||||||
@@ -169,7 +169,7 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
||||||
const adapter = new InMemoryMosCoordinationPort();
|
const adapter = new InMemoryInteractionCoordinationPort();
|
||||||
const handoff = vi.spyOn(adapter, 'handoff');
|
const handoff = vi.spyOn(adapter, 'handoff');
|
||||||
const coordination = service(adapter);
|
const coordination = service(adapter);
|
||||||
await coordination.handoff(
|
await coordination.handoff(
|
||||||
@@ -181,23 +181,23 @@ describe('MosCoordinationService authority boundary', (): void => {
|
|||||||
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'handoff_conflict',
|
code: 'handoff_conflict',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
await expect(
|
await expect(
|
||||||
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'invalid_request',
|
code: 'invalid_request',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
await expect(
|
await expect(
|
||||||
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: 'invalid_request',
|
code: 'invalid_request',
|
||||||
} satisfies Partial<MosCoordinationGatewayError>);
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
||||||
expect(handoff).toHaveBeenCalledTimes(1);
|
expect(handoff).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
||||||
const adapter: MosCoordinationPort = {
|
const adapter: InteractionCoordinationPort = {
|
||||||
handoff: vi.fn(async (handoff: MosHandoff) => ({
|
handoff: vi.fn(async (handoff: Handoff) => ({
|
||||||
handoffId: handoff.handoffId,
|
handoffId: handoff.handoffId,
|
||||||
targetAgentId: 'Unexpected',
|
targetAgentId: 'Unexpected',
|
||||||
status: 'accepted' as const,
|
status: 'accepted' as const,
|
||||||
+47
-44
@@ -1,18 +1,18 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
MosCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
type CoordinationResult,
|
type CoordinationResult,
|
||||||
type CoordinationScope,
|
type CoordinationScope,
|
||||||
type MosCoordinationIdentity,
|
type InteractionCoordinationIdentity,
|
||||||
type MosCoordinationPort,
|
type InteractionCoordinationPort,
|
||||||
type MosHandoffReceipt,
|
type HandoffReceipt,
|
||||||
} from '@mosaicstack/coord';
|
} from '@mosaicstack/coord';
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||||
import type { CreateMosHandoffDto } from './mos-coordination.dto.js';
|
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
||||||
|
|
||||||
export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT');
|
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||||
export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG');
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||||
|
|
||||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
@@ -21,7 +21,7 @@ const MAX_SUMMARY_LENGTH = 2_048;
|
|||||||
const MAX_CONTEXT_LENGTH = 8_192;
|
const MAX_CONTEXT_LENGTH = 8_192;
|
||||||
const MAX_MISSION_ID_LENGTH = 128;
|
const MAX_MISSION_ID_LENGTH = 128;
|
||||||
|
|
||||||
export interface MosCoordinationConfig {
|
export interface InteractionCoordinationConfig {
|
||||||
interactionAgentId?: string;
|
interactionAgentId?: string;
|
||||||
orchestrationAgentId?: string;
|
orchestrationAgentId?: string;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ interface HandoffOwner {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NormalizedMosHandoffRequest {
|
interface NormalizedHandoffRequest {
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
context?: string;
|
context?: string;
|
||||||
@@ -42,31 +42,31 @@ interface NormalizedMosHandoffRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TrackedHandoff {
|
interface TrackedHandoff {
|
||||||
request: NormalizedMosHandoffRequest;
|
request: NormalizedHandoffRequest;
|
||||||
receipt: Promise<MosHandoffReceipt>;
|
receipt: Promise<HandoffReceipt>;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gateway authority boundary for the interaction agent. It derives requester,
|
* Gateway authority boundary for the interaction agent. It derives requester,
|
||||||
* actor, and tenant from trusted server configuration and authentication; no
|
* actor, and tenant from trusted server configuration and authentication; no
|
||||||
* channel request can name a target or gain Mos-owned orchestration verbs.
|
* channel request can name a target or gain orchestrator-owned orchestration verbs.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MosCoordinationService {
|
export class InteractionCoordinationService {
|
||||||
private readonly owners = new Map<string, HandoffOwner>();
|
private readonly owners = new Map<string, HandoffOwner>();
|
||||||
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort,
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||||
@Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig,
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handoff(
|
async handoff(
|
||||||
request: CreateMosHandoffDto,
|
request: CreateHandoffDto,
|
||||||
context: RuntimeProviderRequestContext,
|
context: RuntimeProviderRequestContext,
|
||||||
): Promise<MosHandoffReceipt> {
|
): Promise<HandoffReceipt> {
|
||||||
this.pruneExpiredTracking();
|
this.pruneExpiredTracking();
|
||||||
const normalized = this.normalizeRequest(request);
|
const normalized = this.normalizeRequest(request);
|
||||||
const scope = this.scope(context);
|
const scope = this.scope(context);
|
||||||
@@ -74,9 +74,9 @@ export class MosCoordinationService {
|
|||||||
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
if (!sameRequest(existing.request, normalized)) {
|
if (!sameRequest(existing.request, normalized)) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'handoff_conflict',
|
'handoff_conflict',
|
||||||
'Mos handoff idempotency key is already bound to different immutable input',
|
'Handoff idempotency key is already bound to different immutable input',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return existing.receipt;
|
return existing.receipt;
|
||||||
@@ -122,9 +122,9 @@ export class MosCoordinationService {
|
|||||||
|
|
||||||
private async deliverHandoff(
|
private async deliverHandoff(
|
||||||
handoffId: string,
|
handoffId: string,
|
||||||
request: NormalizedMosHandoffRequest,
|
request: NormalizedHandoffRequest,
|
||||||
scope: CoordinationScope,
|
scope: CoordinationScope,
|
||||||
): Promise<MosHandoffReceipt> {
|
): Promise<HandoffReceipt> {
|
||||||
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
||||||
const owner: HandoffOwner = {
|
const owner: HandoffOwner = {
|
||||||
actorId: scope.actorId,
|
actorId: scope.actorId,
|
||||||
@@ -135,9 +135,9 @@ export class MosCoordinationService {
|
|||||||
};
|
};
|
||||||
const existing = this.owners.get(receipt.handoffId);
|
const existing = this.owners.get(receipt.handoffId);
|
||||||
if (existing !== undefined && !sameOwner(existing, owner)) {
|
if (existing !== undefined && !sameOwner(existing, owner)) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'handoff_conflict',
|
'handoff_conflict',
|
||||||
'Mos handoff ID is already bound to a different authenticated scope',
|
'Handoff ID is already bound to a different authenticated scope',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.owners.set(receipt.handoffId, owner);
|
this.owners.set(receipt.handoffId, owner);
|
||||||
@@ -145,21 +145,21 @@ export class MosCoordinationService {
|
|||||||
return receipt;
|
return receipt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private client(handoffIdFactory?: () => string): MosCoordinationClient {
|
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
|
||||||
return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private identity(): MosCoordinationIdentity {
|
private identity(): InteractionCoordinationIdentity {
|
||||||
const interactionAgentId = this.config.interactionAgentId?.trim();
|
const interactionAgentId = this.config.interactionAgentId?.trim();
|
||||||
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
||||||
if (!interactionAgentId) {
|
if (!interactionAgentId) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'unconfigured_requester',
|
'unconfigured_requester',
|
||||||
'Interaction agent identity is not configured',
|
'Interaction agent identity is not configured',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!orchestrationAgentId) {
|
if (!orchestrationAgentId) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'unconfigured_target',
|
'unconfigured_target',
|
||||||
'Orchestration agent identity is not configured',
|
'Orchestration agent identity is not configured',
|
||||||
);
|
);
|
||||||
@@ -177,9 +177,12 @@ export class MosCoordinationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest {
|
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
|
||||||
if (typeof request !== 'object' || request === null) {
|
if (typeof request !== 'object' || request === null) {
|
||||||
throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid');
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'invalid_request',
|
||||||
|
'Handoff request is invalid',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const idempotencyKey = this.requiredString(
|
const idempotencyKey = this.requiredString(
|
||||||
request.idempotencyKey,
|
request.idempotencyKey,
|
||||||
@@ -203,14 +206,17 @@ export class MosCoordinationService {
|
|||||||
|
|
||||||
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'invalid_request',
|
'invalid_request',
|
||||||
`Mos handoff ${field} must be a string`,
|
`Handoff ${field} must be a string`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const normalized = value.trim();
|
const normalized = value.trim();
|
||||||
if (normalized.length === 0 || normalized.length > maximumLength) {
|
if (normalized.length === 0 || normalized.length > maximumLength) {
|
||||||
throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`);
|
throw new InteractionCoordinationGatewayError(
|
||||||
|
'invalid_request',
|
||||||
|
`Handoff ${field} is invalid`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -245,23 +251,23 @@ export class MosCoordinationService {
|
|||||||
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
||||||
const owner = this.owners.get(handoffId);
|
const owner = this.owners.get(handoffId);
|
||||||
if (owner === undefined) {
|
if (owner === undefined) {
|
||||||
throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found');
|
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
owner.tenantId !== scope.tenantId ||
|
owner.tenantId !== scope.tenantId ||
|
||||||
owner.actorId !== scope.actorId ||
|
owner.actorId !== scope.actorId ||
|
||||||
owner.requesterAgentId !== scope.requesterAgentId
|
owner.requesterAgentId !== scope.requesterAgentId
|
||||||
) {
|
) {
|
||||||
throw new MosCoordinationGatewayError(
|
throw new InteractionCoordinationGatewayError(
|
||||||
'cross_tenant_forbidden',
|
'cross_tenant_forbidden',
|
||||||
'Mos handoff is outside the authenticated scope',
|
'Handoff is outside the authenticated scope',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return owner;
|
return owner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MosCoordinationGatewayErrorCode =
|
export type InteractionCoordinationGatewayErrorCode =
|
||||||
| 'cross_tenant_forbidden'
|
| 'cross_tenant_forbidden'
|
||||||
| 'handoff_conflict'
|
| 'handoff_conflict'
|
||||||
| 'invalid_request'
|
| 'invalid_request'
|
||||||
@@ -277,10 +283,7 @@ function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameRequest(
|
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
|
||||||
left: NormalizedMosHandoffRequest,
|
|
||||||
right: NormalizedMosHandoffRequest,
|
|
||||||
): boolean {
|
|
||||||
return (
|
return (
|
||||||
left.idempotencyKey === right.idempotencyKey &&
|
left.idempotencyKey === right.idempotencyKey &&
|
||||||
left.summary === right.summary &&
|
left.summary === right.summary &&
|
||||||
@@ -289,12 +292,12 @@ function sameRequest(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MosCoordinationGatewayError extends Error {
|
export class InteractionCoordinationGatewayError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: MosCoordinationGatewayErrorCode,
|
readonly code: InteractionCoordinationGatewayErrorCode,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = MosCoordinationGatewayError.name;
|
this.name = InteractionCoordinationGatewayError.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
||||||
|
|
||||||
|
type TierSource =
|
||||||
|
| 'process environment'
|
||||||
|
| 'daemon .env'
|
||||||
|
| 'monorepo-root .env'
|
||||||
|
| 'gateway-local .env'
|
||||||
|
| 'default';
|
||||||
|
|
||||||
|
type BootSource = TierSource | 'mosaic.config.json';
|
||||||
|
|
||||||
|
export interface GatewayDotenvPaths {
|
||||||
|
daemonEnv: string;
|
||||||
|
monorepoRootEnv: string;
|
||||||
|
gatewayLocalEnv: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export function resolveGatewayDotenvPaths(
|
||||||
|
anchor: string = here,
|
||||||
|
homeBase: string = homedir(),
|
||||||
|
): GatewayDotenvPaths {
|
||||||
|
return {
|
||||||
|
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
||||||
|
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
||||||
|
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGatewayConfigPath(anchor: string = here): string {
|
||||||
|
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
||||||
|
const gatewayHome = resolve(
|
||||||
|
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
||||||
|
);
|
||||||
|
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
||||||
|
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
||||||
|
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||||
|
|
||||||
|
if (existsSync(daemonConfig)) {
|
||||||
|
return daemonConfig;
|
||||||
|
}
|
||||||
|
if (existsSync(gatewayLocalConfig)) {
|
||||||
|
return gatewayLocalConfig;
|
||||||
|
}
|
||||||
|
if (existsSync(monorepoRootConfig)) {
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
|
||||||
|
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
|
||||||
|
anchor,
|
||||||
|
homeBase,
|
||||||
|
);
|
||||||
|
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
||||||
|
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
||||||
|
let databaseUrlSource: TierSource =
|
||||||
|
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
||||||
|
|
||||||
|
function loadAnchoredDotenv(
|
||||||
|
path: string,
|
||||||
|
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
||||||
|
): void {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
||||||
|
config({ path, quiet: true });
|
||||||
|
|
||||||
|
if (
|
||||||
|
beforeTier === undefined &&
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
||||||
|
tierSource === 'default'
|
||||||
|
) {
|
||||||
|
tierSource = sourceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
beforeDatabaseUrl === undefined &&
|
||||||
|
process.env['DATABASE_URL'] !== undefined &&
|
||||||
|
databaseUrlSource === 'default'
|
||||||
|
) {
|
||||||
|
databaseUrlSource = sourceLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .env from daemon config dir (global install / daemon mode) first.
|
||||||
|
// It takes precedence over file-based local-dev configuration.
|
||||||
|
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
||||||
|
|
||||||
|
// Load .env from the anchored monorepo root, then fill any remaining values
|
||||||
|
// from apps/gateway/.env when present.
|
||||||
|
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
||||||
|
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
||||||
|
|
||||||
|
const envOnlyTier = detectFromEnv().tier;
|
||||||
|
const configPath = resolveGatewayConfigPath(anchor);
|
||||||
|
const anchoredConfigExists = existsSync(configPath);
|
||||||
|
const resolvedTier = loadConfig(configPath).tier;
|
||||||
|
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
|
||||||
|
const recognizedTierDeterminesTier =
|
||||||
|
(configuredTier === 'federated' ||
|
||||||
|
configuredTier === 'standalone' ||
|
||||||
|
configuredTier === 'local') &&
|
||||||
|
configuredTier === envOnlyTier;
|
||||||
|
|
||||||
|
let source: BootSource;
|
||||||
|
if (anchoredConfigExists) {
|
||||||
|
source = 'mosaic.config.json';
|
||||||
|
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
||||||
|
source = databaseUrlSource;
|
||||||
|
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
||||||
|
source = tierSource;
|
||||||
|
} else {
|
||||||
|
source = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGatewayEnv();
|
||||||
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +52,15 @@ 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).
|
||||||
|
if (this.redis) {
|
||||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||||
const valkeyKeys = await this.scanKeys(pattern);
|
const valkeyKeys = await this.scanKeys(pattern);
|
||||||
if (valkeyKeys.length > 0) {
|
if (valkeyKeys.length > 0) {
|
||||||
await this.redis.del(...valkeyKeys);
|
await this.redis.del(...valkeyKeys);
|
||||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
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.
|
||||||
const cutoff = new Date();
|
const cutoff = new Date();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import * as nodeOs from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import * as nodeUrl from 'node:url';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import type * as MosaicStorage from '@mosaicstack/storage';
|
||||||
|
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||||
|
|
||||||
|
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||||
|
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||||
|
return { ...process.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in snapshot)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(snapshot)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||||
|
const relativePath = relative(tempRoot, path);
|
||||||
|
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BootstrapPreflightResult {
|
||||||
|
capturedConfig: MosaicConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBootstrapPreflight(
|
||||||
|
anchoredConfigContents: string,
|
||||||
|
ambientConfigContents: string,
|
||||||
|
): Promise<BootstrapPreflightResult> {
|
||||||
|
const originalEnv = snapshotProcessEnv();
|
||||||
|
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
|
||||||
|
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let exitSpy: MockInstance<typeof process.exit> | undefined;
|
||||||
|
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let capturedConfig: MosaicConfig | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||||
|
const homePath = join(tempRoot, 'home');
|
||||||
|
const cwdPath = join(tempRoot, 'ambient', 'cwd');
|
||||||
|
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||||
|
|
||||||
|
await mkdir(anchor, { recursive: true });
|
||||||
|
await mkdir(cwdPath, { recursive: true });
|
||||||
|
|
||||||
|
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
|
||||||
|
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
|
||||||
|
|
||||||
|
process.env['HOME'] = homePath;
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
|
||||||
|
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['VALKEY_URL'];
|
||||||
|
|
||||||
|
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||||
|
const exitMock = vi.fn<typeof process.exit>();
|
||||||
|
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||||
|
vi.doMock('node:url', () => ({
|
||||||
|
...nodeUrl,
|
||||||
|
fileURLToPath: (url: string | URL): string => {
|
||||||
|
const actualPath = nodeUrl.fileURLToPath(url);
|
||||||
|
if (
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||||
|
) {
|
||||||
|
return join(anchor, 'env.ts');
|
||||||
|
}
|
||||||
|
return actualPath;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||||
|
vi.doMock('./tracing.js', () => ({}));
|
||||||
|
|
||||||
|
const preflightSentinel = new Error('preflight-capture-sentinel');
|
||||||
|
vi.doMock('@mosaicstack/storage', async () => {
|
||||||
|
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
|
||||||
|
capturedConfig = config;
|
||||||
|
throw preflightSentinel;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await import('./main.js');
|
||||||
|
await vi.waitFor((): void => {
|
||||||
|
expect(exitSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { capturedConfig };
|
||||||
|
} finally {
|
||||||
|
cwdSpy?.mockRestore();
|
||||||
|
exitSpy?.mockRestore();
|
||||||
|
consoleInfoSpy?.mockRestore();
|
||||||
|
vi.doUnmock('@mosaicstack/storage');
|
||||||
|
vi.doUnmock('./tracing.js');
|
||||||
|
vi.doUnmock('node:url');
|
||||||
|
vi.doUnmock('node:os');
|
||||||
|
vi.resetModules();
|
||||||
|
restoreProcessEnv(originalEnv);
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('main bootstrap preflight config anchoring', (): void => {
|
||||||
|
it(
|
||||||
|
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const anchoredConfig = JSON.stringify({
|
||||||
|
tier: 'local',
|
||||||
|
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||||
|
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
const ambientConfig = JSON.stringify({
|
||||||
|
tier: 'federated',
|
||||||
|
storage: {
|
||||||
|
type: 'postgres',
|
||||||
|
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
|
||||||
|
|
||||||
|
expect(capturedConfig?.tier).toBe('local');
|
||||||
|
expect(capturedConfig?.storage).not.toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { config } from 'dotenv';
|
import './env.js';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { resolve, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
// Load .env from daemon config dir (global install / daemon mode).
|
|
||||||
// Loaded first so monorepo .env can override for local dev.
|
|
||||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
|
||||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
|
||||||
|
|
||||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
|
||||||
config({ path: resolve(process.cwd(), '../../.env') });
|
|
||||||
config(); // Also load apps/gateway/.env if present (overrides)
|
|
||||||
|
|
||||||
import './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -26,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
|||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
@@ -37,7 +25,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
// Pre-flight: assert all external services required by the configured tier
|
// Pre-flight: assert all external services required by the configured tier
|
||||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||||
// immediately with actionable remediation hints.
|
// immediately with actionable remediation hints.
|
||||||
const mosaicConfig = loadConfig();
|
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
||||||
try {
|
try {
|
||||||
await detectAndAssertTier(mosaicConfig);
|
await detectAndAssertTier(mosaicConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const ENV_KEYS = [
|
|||||||
'DISCORD_ALLOWED_CHANNEL_IDS',
|
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||||
'DISCORD_ALLOWED_USER_IDS',
|
'DISCORD_ALLOWED_USER_IDS',
|
||||||
'MOSAIC_AGENT_NAME',
|
'MOSAIC_AGENT_NAME',
|
||||||
|
'MOSAIC_AGENT_CONFIG_ID',
|
||||||
] as const;
|
] as const;
|
||||||
const savedEnv = new Map<string, string | undefined>();
|
const savedEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
@@ -33,12 +34,14 @@ function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
|||||||
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
||||||
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
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_GUILD_IDS'] = 'guild-001';
|
||||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
{
|
{
|
||||||
instanceId: 'Nova',
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
guildId: 'guild-001',
|
guildId: 'guild-001',
|
||||||
channelId: 'channel-001',
|
channelId: 'channel-001',
|
||||||
pairedUsers: {
|
pairedUsers: {
|
||||||
@@ -141,7 +144,7 @@ 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,
|
||||||
};
|
};
|
||||||
@@ -153,6 +156,7 @@ describe('Discord ingress security', () => {
|
|||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
{
|
{
|
||||||
instanceId: 'Nova',
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
guildId: 'guild-001',
|
guildId: 'guild-001',
|
||||||
channelId: 'channel-001',
|
channelId: 'channel-001',
|
||||||
pairedUsers: { 'user-001': 'admin' },
|
pairedUsers: { 'user-001': 'admin' },
|
||||||
@@ -170,6 +174,7 @@ describe('Discord ingress security', () => {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
instanceId: 'Nova',
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
guildId: 'guild-001',
|
guildId: 'guild-001',
|
||||||
channelId: 'channel-001',
|
channelId: 'channel-001',
|
||||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||||
@@ -307,27 +312,12 @@ describe('Discord ingress security', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it('rejects approval when the durable session targets a different logical agent', async () => {
|
||||||
[
|
configureDiscordEnv();
|
||||||
'binding',
|
const { gateway, client, durable } = discordGateway('admin');
|
||||||
() => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Other';
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'durable session',
|
|
||||||
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
|
|
||||||
durable.getSnapshot.mockResolvedValueOnce({
|
durable.getSnapshot.mockResolvedValueOnce({
|
||||||
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||||
});
|
});
|
||||||
},
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
'rejects approval when the %s targets a different runtime agent',
|
|
||||||
async (_source, configure) => {
|
|
||||||
configureDiscordEnv();
|
|
||||||
const { gateway, client, durable } = discordGateway('admin');
|
|
||||||
configure(durable);
|
|
||||||
|
|
||||||
await gateway.handleDiscordApproval(
|
await gateway.handleDiscordApproval(
|
||||||
client as never,
|
client as never,
|
||||||
@@ -340,8 +330,28 @@ describe('Discord ingress security', () => {
|
|||||||
approvalId: undefined,
|
approvalId: undefined,
|
||||||
expiresAt: 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 () => {
|
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
||||||
configureDiscordEnv();
|
configureDiscordEnv();
|
||||||
@@ -398,6 +408,267 @@ describe('Discord ingress security', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('accepts a thread message through its allowed bound parent channel', () => {
|
||||||
const emitted = vi.fn();
|
const emitted = vi.fn();
|
||||||
const plugin = new DiscordPlugin({
|
const plugin = new DiscordPlugin({
|
||||||
@@ -410,6 +681,7 @@ describe('Discord ingress security', () => {
|
|||||||
interactionBindings: [
|
interactionBindings: [
|
||||||
{
|
{
|
||||||
instanceId: 'Nova',
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
guildId: 'guild-001',
|
guildId: 'guild-001',
|
||||||
channelId: 'channel-001',
|
channelId: 'channel-001',
|
||||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||||
|
|||||||
@@ -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,6 +92,10 @@ 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'),
|
||||||
|
|||||||
@@ -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,22 +110,43 @@ 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 {
|
||||||
|
if (this.enabled) {
|
||||||
this.logger.log('QueueService initialised (BullMQ)');
|
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> {
|
||||||
|
if (this.enabled) {
|
||||||
await this.closeAll();
|
await this.closeAll();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Queue helpers
|
// Queue helpers
|
||||||
@@ -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,104 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
type INestApplication,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
|
import { WorkspaceController } from './workspace.controller.js';
|
||||||
|
|
||||||
|
const bootstrapMock = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
projectId: 'project-1',
|
||||||
|
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authGuard: CanActivate = {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||||
|
requestContext.user = { id: 'user-1' };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('POST /api/workspaces repoUrl validation', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [WorkspaceController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ProjectBootstrapService,
|
||||||
|
useValue: { bootstrap: bootstrapMock },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(authGuard)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bootstrapMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a leading-dash value', '--upload-pack=sh -c id'],
|
||||||
|
['an ext remote helper', 'ext::sh -c id'],
|
||||||
|
['a file URL', 'file:///tmp/repository'],
|
||||||
|
['an unparseable value', 'not a url'],
|
||||||
|
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||||
|
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||||
|
['a hostless git URL', 'git:///tmp/repository'],
|
||||||
|
])('returns 400 for %s', async (_description, repoUrl) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/workspaces')
|
||||||
|
.send({ name: 'Example', repoUrl })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(bootstrapMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
|
||||||
|
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
|
||||||
|
])('accepts %s', async (_description, repoUrl) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/workspaces')
|
||||||
|
.send({ name: 'Example', repoUrl })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(bootstrapMock).toHaveBeenCalledWith({
|
||||||
|
name: 'Example',
|
||||||
|
description: undefined,
|
||||||
|
userId: 'user-1',
|
||||||
|
teamId: undefined,
|
||||||
|
repoUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
import {
|
||||||
|
ProjectBootstrapService,
|
||||||
|
type BootstrapProjectResult,
|
||||||
|
} from './project-bootstrap.service.js';
|
||||||
|
import { CreateWorkspaceDto } from './workspace.dto.js';
|
||||||
|
|
||||||
@Controller('api/workspaces')
|
@Controller('api/workspaces')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -11,20 +15,14 @@ export class WorkspaceController {
|
|||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body()
|
@Body() dto: CreateWorkspaceDto,
|
||||||
body: {
|
): Promise<BootstrapProjectResult> {
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
teamId?: string;
|
|
||||||
repoUrl?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return this.bootstrap.bootstrap({
|
return this.bootstrap.bootstrap({
|
||||||
name: body.name,
|
name: dto.name,
|
||||||
description: body.description,
|
description: dto.description,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: body.teamId,
|
teamId: dto.teamId,
|
||||||
repoUrl: body.repoUrl,
|
repoUrl: dto.repoUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateWorkspaceDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
teamId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^(?:https|git):\/\//i, {
|
||||||
|
message: 'repoUrl must be a valid https:// or git:// URL',
|
||||||
|
})
|
||||||
|
@IsUrl(
|
||||||
|
{
|
||||||
|
protocols: ['https', 'git'],
|
||||||
|
require_host: true,
|
||||||
|
require_protocol: true,
|
||||||
|
require_tld: false,
|
||||||
|
require_valid_protocol: true,
|
||||||
|
},
|
||||||
|
{ message: 'repoUrl must be a valid https:// or git:// URL' },
|
||||||
|
)
|
||||||
|
repoUrl?: string;
|
||||||
|
}
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { WorkspaceService } from './workspace.service.js';
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
|
||||||
|
type ExecFileMock = (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options: { cwd: string },
|
||||||
|
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
const { execFileMock } = vi.hoisted(() => ({
|
||||||
|
execFileMock: vi.fn<ExecFileMock>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
execFile: execFileMock,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('WorkspaceService', () => {
|
describe('WorkspaceService', () => {
|
||||||
let service: WorkspaceService;
|
let service: WorkspaceService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
execFileMock.mockReset();
|
||||||
|
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||||
|
callback(null, '', '');
|
||||||
|
});
|
||||||
service = new WorkspaceService();
|
service = new WorkspaceService();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,4 +98,69 @@ describe('WorkspaceService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const project = {
|
||||||
|
id: 'project-1',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user-1',
|
||||||
|
teamId: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let originalRoot: string | undefined;
|
||||||
|
let temporaryRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
originalRoot = process.env['MOSAIC_ROOT'];
|
||||||
|
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
||||||
|
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
||||||
|
service = new WorkspaceService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalRoot === undefined) {
|
||||||
|
delete process.env['MOSAIC_ROOT'];
|
||||||
|
} else {
|
||||||
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||||
|
}
|
||||||
|
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a leading-dash URL', '--upload-pack=sh -c id'],
|
||||||
|
['an ext remote helper', 'ext::sh -c id'],
|
||||||
|
['a file URL', 'file:///tmp/repository'],
|
||||||
|
['an unparseable value', 'not a url'],
|
||||||
|
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||||
|
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||||
|
['a hostless git URL', 'git:///tmp/repository'],
|
||||||
|
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
||||||
|
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(execFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
||||||
|
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
||||||
|
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
||||||
|
const workspacePath = await service.create(project, repoUrl);
|
||||||
|
|
||||||
|
expect(execFileMock).toHaveBeenCalledOnce();
|
||||||
|
expect(execFileMock).toHaveBeenCalledWith(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.ext.allow=never',
|
||||||
|
'-c',
|
||||||
|
'protocol.file.allow=never',
|
||||||
|
'clone',
|
||||||
|
'--',
|
||||||
|
repoUrl,
|
||||||
|
'.',
|
||||||
|
],
|
||||||
|
{ cwd: workspacePath },
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
||||||
|
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
||||||
|
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
||||||
|
|
||||||
|
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
||||||
|
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(repoUrl);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceProject {
|
export interface WorkspaceProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,14 +59,32 @@ export class WorkspaceService {
|
|||||||
* If repoUrl is provided, clone instead of init.
|
* If repoUrl is provided, clone instead of init.
|
||||||
*/
|
*/
|
||||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
|
if (repoUrl !== undefined) {
|
||||||
|
assertAllowedRepositoryUrl(repoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const workspacePath = this.resolvePath(project);
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
// Create directory
|
// Create directory
|
||||||
await fs.mkdir(workspacePath, { recursive: true });
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
if (repoUrl) {
|
if (repoUrl !== undefined) {
|
||||||
// Clone existing repo
|
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
||||||
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
// disabled and terminates option parsing before positional arguments.
|
||||||
|
await execFileAsync(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.ext.allow=never',
|
||||||
|
'-c',
|
||||||
|
'protocol.file.allow=never',
|
||||||
|
'clone',
|
||||||
|
'--',
|
||||||
|
repoUrl,
|
||||||
|
'.',
|
||||||
|
],
|
||||||
|
{ cwd: workspacePath },
|
||||||
|
);
|
||||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
} else {
|
} else {
|
||||||
// Init new git repo
|
// Init new git repo
|
||||||
|
|||||||
@@ -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;
|
let cancelled = false;
|
||||||
|
|
||||||
if (!currentProvider) {
|
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.');
|
setError('Unknown SSO provider.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentProvider.enabled) {
|
setProviderName(provider.name);
|
||||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeProvider = currentProvider;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
|
||||||
const result = await signIn.oauth2({
|
const result = await signIn.oauth2({
|
||||||
providerId: activeProvider.id,
|
providerId: provider.id,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cancelled && result?.error) {
|
if (!cancelled && result?.error) {
|
||||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
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,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
COPY plugins/ ./plugins/
|
||||||
|
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||||
@@ -21,11 +23,22 @@ RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
# WorkspaceService shells out to git at runtime and roots workspaces at
|
||||||
|
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
||||||
|
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
||||||
|
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
||||||
|
# git was declined so routine base-image security updates remain maintainable.
|
||||||
|
RUN apk add --no-cache git \
|
||||||
|
&& mkdir -p /opt/mosaic/.workspaces \
|
||||||
|
&& chown -R node:node /opt/mosaic /app
|
||||||
|
ENV MOSAIC_ROOT=/opt/mosaic
|
||||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||||
COPY --from=builder /deploy/node_modules ./node_modules
|
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
||||||
COPY --from=builder /deploy/package.json ./package.json
|
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
||||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
# copy from builder explicitly as belt-and-suspenders
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
||||||
EXPOSE 4000
|
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
||||||
|
EXPOSE 14242
|
||||||
|
USER node
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ WORKDIR /app
|
|||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
|
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @mosaic/web build
|
RUN pnpm --filter @mosaicstack/web build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -68,10 +68,11 @@ 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+280
-12
@@ -79,6 +79,174 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Compaction Refresh Trust Lifecycle (M1, #827–#830)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
Context compaction, session replacement, and same-PID runtime reloads can leave a previously VERIFIED runtime lease attached to stale directives. M1 must revoke that authority mechanically for Claude (including Claudex) and Pi without trusting caller-asserted identity or forking the external broker state machine.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
1. `CR-REQ-01`: Claude `PreCompact` and `SessionStart` with matcher `compact`, plus Pi `session_before_compact` and the first post-`session_compact` `context`, SHALL independently revoke the active broker lease.
|
||||||
|
2. `CR-REQ-02`: Runtime generation increases—including same-PID Pi reload/new/resume/fork and Claude resume/clear—SHALL monotonically replace the prior broker incarnation and inherit no VERIFIED lease.
|
||||||
|
3. `CR-REQ-03`: A fired observer that cannot confirm broker revocation SHALL fail closed through lifecycle cancellation, a private local generation fence, and/or a runtime-local tool latch. The existing all-tools broker gate remains authoritative.
|
||||||
|
4. `CR-REQ-04`: The lease TTL SHALL remain monotonic and capped at 300 seconds. If both observers are missed, within-TTL consequential actions remain allowed and after-TTL actions are denied. This named bounded residual stale window SHALL be documented without claiming a mutator-action bound inside the window.
|
||||||
|
5. `CR-REQ-05`: Hook descendants SHALL use the broker-minted session and owner-only current-generation state inherited from register-before-exec. Caller-minted sessions and parallel lease state machines remain forbidden.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-CR-01`: Real-socket tests prove each Claude observer revokes, Pi lifecycle tests prove both observer paths, and Claudex isolated settings preserve and install the mandatory hooks.
|
||||||
|
2. `AC-CR-02`: A same-PID generation test proves the old generation is stale and the replacement generation is UNVERIFIED across reload/resume/fork-equivalent lifecycle events.
|
||||||
|
3. `AC-CR-03`: RED-first T12b/T30 evidence explicitly reports dual-hook miss within TTL as **ALLOWED** and after TTL as **DENIED**.
|
||||||
|
4. `AC-CR-04`: Attributable executable coverage is at least 85%, the full repository suite is green on deterministic main, and independent code/security review completes before merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
The local Mosaic fleet has a roster, generated agent environment files, user-systemd units, tmux
|
||||||
|
sessions, heartbeat files, examples, profiles, and separate gateway-backed agent records. These
|
||||||
|
planes have drifted and are not one safe operator lifecycle. The objective is one **local fleet
|
||||||
|
roster** as the desired-state SSOT, with generated environment, systemd, tmux, and heartbeat
|
||||||
|
artifacts as rebuildable projections; it does not merge the local fleet control plane with the
|
||||||
|
gateway-backed agent catalog.
|
||||||
|
|
||||||
|
### Normative requirements
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
||||||
|
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
||||||
|
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
||||||
|
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
||||||
|
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
||||||
|
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
||||||
|
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
||||||
|
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
||||||
|
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
||||||
|
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-FCM-01`: A valid local v2 roster can be parsed from YAML or JSON, validated structurally and semantically through the shared resolver, and rendered canonically; invalid fields, duplicate names, unresolved classes, unsupported runtime/model combinations, socket ambiguity, and incompatible options fail closed.
|
||||||
|
2. `AC-FCM-02`: `plan` reports deterministic desired-versus-observed differences for roster, generated environment, systemd enablement, tmux/session, heartbeat, installed-asset revision, and provable orphans without mutation; `apply --check` reports drift without mutation.
|
||||||
|
3. `AC-FCM-03`: Local create/update/delete is generation-guarded, atomic, idempotent, and safe by default; it permits supported runtime/model/harness/effort/workdir/role changes without direct editing of generated environment files and does not start a newly created agent unless explicitly persisted.
|
||||||
|
4. `AC-FCM-04`: The generated-env/local-override launch chain rejects generated-key shadowing, arbitrary command override, unknown keys, shell evaluation, and sensitive-value diagnostics before any agent starts; known-safe legacy input is regenerated or strictly relocated, and forbidden input is quarantined.
|
||||||
|
5. `AC-FCM-05`: Local lifecycle reconciliation implements the persisted/transient start-stop rules, exact default/named tmux socket targeting, systemd/tmux status, stale generated state, unmanaged-session reporting, and rollback without surprise restarts or fuzzy destructive targeting.
|
||||||
|
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
|
||||||
|
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
|
||||||
|
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
|
||||||
|
|
||||||
|
### M0 implementation gate
|
||||||
|
|
||||||
|
No source, schema, role, example, profile, systemd, or live-fleet change is authorized before M0
|
||||||
|
lands. M0 consists only of these normative requirements, the complete task DAG, the scoped
|
||||||
|
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
||||||
|
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
Fleet runtime contracts currently combine exact peer rows with generic operational metavariables and
|
||||||
|
independently parsed roster data. Non-Claude harnesses can mistake those metavariables for values to
|
||||||
|
infer, producing incorrect host, session, socket, or helper targets. The objective is one
|
||||||
|
roster-resolved communications contract that every supported harness receives unchanged.
|
||||||
|
|
||||||
|
### Normative requirements
|
||||||
|
|
||||||
|
1. `FCOM-REQ-01`: Fleet commands and runtime composition SHALL use one shared v1 roster structural
|
||||||
|
resolver. A second lenient communications parser is forbidden.
|
||||||
|
2. `FCOM-REQ-02`: The composed contract SHALL render the local roster member's authoritative host,
|
||||||
|
exact agent/session name, resolved tmux socket, exact helper path, and deterministic communications
|
||||||
|
generation.
|
||||||
|
3. `FCOM-REQ-03`: Every known peer SHALL have one exact executable command. Same-host commands SHALL
|
||||||
|
omit `-H`; cross-host commands SHALL use only that peer's explicit roster `ssh` target; the one
|
||||||
|
supported fleet-wide named socket SHALL use `-L` with its exact value. A per-agent socket declaration
|
||||||
|
must equal that fleet-wide value; unsupported independent sockets and missing cross-host SSH data SHALL
|
||||||
|
fail closed.
|
||||||
|
4. `FCOM-REQ-04`: Operational fleet examples SHALL not contain unresolved host, session, socket, or
|
||||||
|
helper-path metavariables. Agents SHALL select an exact rendered peer row and SHALL NOT infer,
|
||||||
|
substitute, or fuzzy-match targeting values.
|
||||||
|
5. `FCOM-REQ-05`: An unknown local member or requested peer SHALL fail closed with exact-name discovery
|
||||||
|
guidance. Runtime composition SHALL not silently omit a requested fleet member's communications
|
||||||
|
contract.
|
||||||
|
6. `FCOM-REQ-06`: Claude Code, Codex, OpenCode, and Pi SHALL receive equivalent authoritative
|
||||||
|
communications data through the common runtime composer.
|
||||||
|
7. `FCOM-REQ-07`: Tests SHALL prove the contract from framework-source `TOOLS.md`, through a fresh
|
||||||
|
installed `TOOLS.md`, to final runtime composition and helper executability. User-owned installed
|
||||||
|
`TOOLS.md` content SHALL remain preserved.
|
||||||
|
8. `FCOM-REQ-08`: Stale installed or active composed context SHALL be reported with deterministic
|
||||||
|
generation/repair/relaunch guidance. Currency requires the expected source and installed contract
|
||||||
|
marker/version plus bounded byte equality. The supported current-version repair SHALL run independently
|
||||||
|
of package updates, preserve divergent `TOOLS.md` bytes in a digest-qualified no-clobber backup, restore
|
||||||
|
a regular executable helper without following symlinks, and be idempotent. Detection and reporting SHALL
|
||||||
|
NOT rewrite active context, restart a session, or mutate a live fleet.
|
||||||
|
9. `FCOM-REQ-09`: The shared resolver SHALL preserve and strictly validate every schema-supported v1
|
||||||
|
connector kind (`tmux`, `discord`, and `matrix`) from YAML and JSON. Every accepted snake/camel alias
|
||||||
|
pair SHALL reject differing dual declarations and accept identical declarations. JSON roster fallback
|
||||||
|
SHALL occur only when `roster.yaml` is absent; all other YAML access failures SHALL fail closed.
|
||||||
|
10. `FCOM-REQ-10`: The communications generation SHALL cover the complete canonical rendered semantic
|
||||||
|
contract, including identity, role/class, resolved host/socket/helper, peer metadata, and exact commands.
|
||||||
|
Installed helpers SHALL be validated with no-follow filesystem inspection as regular executable files.
|
||||||
|
Keep-mode reseed and relaunch discovery SHALL preserve and support both YAML and JSON rosters.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-FCOM-01`: Contract fixtures contain no unresolved operational targeting metavariables; local
|
||||||
|
identity contains exact host/session/socket/helper values.
|
||||||
|
2. `AC-FCOM-02`: Same-host, cross-host, named-socket, literal-default-socket, and missing-SSH tests prove
|
||||||
|
exact targeting and fail-closed behavior.
|
||||||
|
3. `AC-FCOM-03`: Unknown identities and peers report known exact names plus an exact self-scoped
|
||||||
|
discovery command; no fuzzy session selection is emitted.
|
||||||
|
4. `AC-FCOM-04`: Four-harness tests prove byte-equal authoritative communications sections.
|
||||||
|
5. `AC-FCOM-05`: Source, fresh-install, preserved-custom-install, stale-installed, composed-generation,
|
||||||
|
helper executable, agent-send socket isolation, and exact-target tests pass.
|
||||||
|
6. `AC-FCOM-06`: Documentation defines non-mutating stale-context detection and operator-authorized,
|
||||||
|
exact-agent relaunch; no implementation path performs automatic session mutation.
|
||||||
|
7. `AC-FCOM-07`: YAML and JSON fixtures cover every connector kind; all snake/camel aliases cover
|
||||||
|
identical acceptance and conflicting rejection; non-`ENOENT` YAML failures do not fall back.
|
||||||
|
8. `AC-FCOM-08`: Missing, directory, symlink, and non-executable installed helpers fail closed. Explicit
|
||||||
|
current-version repair proves partial-deletion recovery, digest-qualified backup collision safety,
|
||||||
|
symlink-target safety, and repeated-run idempotence.
|
||||||
|
9. `AC-FCOM-09`: Markerless-equal and wrong-version source/installed contracts are stale, and a rendered
|
||||||
|
role/class change produces a different communications generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KBN-101 Database Runtime/Migration Role Split (#771)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
PostgreSQL Gateway/storage currently uses one `DATABASE_URL` for runtime queries and migrations. That makes the deployed application identity an owner and prevents certification that KBN immutable event, artifact, checkpoint, and evidence relations reject runtime `UPDATE`/`DELETE`. KBN-101 freezes a least-privilege runtime/migration split before KBN-100 schema work.
|
||||||
|
|
||||||
|
### Normative requirements
|
||||||
|
|
||||||
|
1. `K101-REQ-01`: `DATABASE_URL` SHALL be the non-owner PostgreSQL runtime connection and `DATABASE_MIGRATION_URL` SHALL be the migration-only owner/migrator connection. They are required respectively for runtime and the dedicated `mosaic-db-migrator --run|--verify` phase in `standalone`/`federated`; local PGlite is the explicit exception. The published `@mosaicstack/db` bin maps exactly `mosaic-db-migrator` to `./dist/cli.js`, its image entrypoint is exactly `mosaic-db-migrator`, accepts no URL/SQL/schema/role argv, and returns stable sanitized exits. Every current/future PostgreSQL DDL entrypoint SHALL route to that runner or be denied, and SHALL reject `DATABASE_URL`-only execution before connection/DDL. Data migration may connect only after the runner prepares and verifies the PostgreSQL target, through dedicated non-DDL `mosaic_data_importer` and exactly `--target-url-file /run/secrets/mosaic-migrate-target-url`, its fixed paired authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`, plus `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. KBN-101-05 obtains URL key `url` and version only from the same successful Vault KV-v2 response at `secret-{env}/mosaic-stack/database/importer` (`data.metadata.version`), renders them as one immutable generation into separate consumer copies, and never infers a provider version from DSN bytes. The trusted runner verifies TLS/identity/manifest, reads its fixed importer URL/version copies only for binding through safe no-follow fd checks, and signs a credential-free JCS/Ed25519 attestation using its runner-only fixed root-owned private-key file; no signing key reaches importer/runtime. The artifact binds secret version and SHA-256 of exact high-entropy credential-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, importer role, manifest/schema fingerprints, producer invocation/build/image digest, issued/expires/nonce, and correlation. Before target connection the importer validates URL/version/attestation/public-key files, signature/key/expiry/replay/authenticated provider version/digest/generation/bindings and the importer-only CA at exact `DATABASE_TLS_CA_CERT_PATH`; after verified TLS and before DML it validates server/database/role/CA/schema identity, with same-fd/in-memory-byte TOCTOU protection, rotation/revocation, a privileged producer-only-to-importer-only artifact handoff controller that verifies/copies/fsyncs/atomically renames/seals before importer start, consumer isolation/no logging-oracle, and sanitized errors. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner use, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and DDL attempt fail before target connection/DDL; post-connect mismatch closes with zero DML/DDL. A reviewed finite classifier inventories executable current source/scripts/package bins, operator docs, deploy manifests, and exact normative contracts by path; active secure records pin both options/files, producer/key/bindings/tests, while normative contracts cannot mask instructions. Unknown active commands, duplicate-owner, ownerless, missing-path, and historical/status-only masking hits fail. `db:push` is forbidden outside an explicitly disposable local developer database and cannot accept a production-like URL.
|
||||||
|
2. `K101-REQ-02`: Gateway runtime/replicas SHALL not execute migrations or DDL. The runner SHALL hold one `max:1` session and fixed two-int advisory namespace `1297044289` (`MOSA`), `1262636593` (`KBN1`) across preflight, reconciliation, migration, verification, and release. It SHALL compare the versioned canonical manifest v1 tuple (journal logical index/tag plus exact SQL-byte SHA-256) to the complete observed ledger mapping; count/set-only, timestamps, and physical insertion order are non-normative and insufficient.
|
||||||
|
3. `K101-REQ-03`: PostgreSQL SHALL separate non-login platform database owner, non-login schema owner, dedicated `NOLOGIN SUPERUSER` `mosaic_extension_owner`, login migrator, dedicated login non-DDL data importer, non-login runtime capability, and login runtime roles. For PostgreSQL 17 + pgvector 0.8.2, `vector` is untrusted (`trusted` is absent and `relocatable=true`): only an externally controlled audited platform-bootstrap superuser session may `SET ROLE mosaic_extension_owner` for CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; the role has `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and is never provided to app containers. It owns `mosaic_extensions`, fresh `vector`, and owner-bearing extension members, while `mosaic_schema_owner` receives only `USAGE` for type resolution and never ownership/`CREATE`/`ALTER`/`DROP`/member-change/default-privilege authority there. Superuser cannot be constrained by `GRANT`/`REVOKE`; this is identity/non-login/no-membership/external-control/audit isolation, not a false least-privilege claim. Extension operations require control-plane change, independent review, backup/rollback, maintenance window, and audit evidence. Managed targets that cannot establish this exact role are ineligible until an independently approved versioned provider-owned extension-owner profile exists; app/migrator ownership is never silently retained. Existing approved-owner extension relocation validates exact `pg_namespace.nspowner`, `pg_extension.extowner`, member ownership/schema/version, while legacy runtime-owned extension fails closed to a controlled shadow-database migration—never unsupported ownership alteration, catalog mutation, ownership adoption, or `DROP CASCADE`. Runtime, migrator, schema owner, importer, and all service roles must fail `SET ROLE`, catalog/direct `ALTER`/`UPDATE`/`DROP`/membership-change denial, role ownership, superuser/role-creation/schema-creation/TEMPORARY, unsafe membership, untrusted search path, missing grants, unauthenticated TLS, and immutable privilege drift checks. Application schema is fixed `mosaic` with exact `pg_catalog,mosaic` session path; historical public migrations remain byte-immutable legacy bootstrap only, every future Drizzle application declaration targets `mosaic`, and `vector` is explicitly qualified from non-writable `mosaic_extensions`. No config-derived SQL identifier is permitted.
|
||||||
|
4. `K101-REQ-04`: `mosaicstack/stack` KBN-101-00 SHALL exclusively own `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests; KBN-101-05 SHALL exclusively own `tools/db/render-postgres-secrets.ts`, its tests, and current Compose/Portainer/two-gateway deployment declarations, consuming the versioned bootstrap interface without overlap. Environment IaC/Vault is named input and Mosaic deployment control plane/Jason is activation authority. Distinct runtime/migrator/importer URL, importer authenticated provider-version, DB-client CA, Gateway leaf, and PostgreSQL server key/certificate materials are provisioned before a production-like database starts. Importer and migrator have separate immutable URL/version copies at fixed `10002:10002`/`10003:10003` identities; runtime/unrelated containers receive neither importer material, attestation private key, or importer artifact. Runtime, migrator, and importer require their mounted CA plus `sslmode=verify-full`. Exact UID/GID/mode/rendering, service-DNS SANs, Vault/compose/Swarm consumer isolation, two-gateway pair ordering, server activation, pre-enforcement legacy-client drain and `hostssl` zero-plaintext-session proof, fresh/existing transition, CA-overlap rotation, TLS-only rollback, and standalone/federated/Swarm/two-gateway positive/negative TLS evidence are required. No application-generated production certificate or plaintext bootstrap exception is permitted.
|
||||||
|
5. `K101-REQ-05`: KBN immutable relations SHALL permit the real runtime role INSERT/SELECT only and deny UPDATE/DELETE; parent retention remains RESTRICT/no-cascade. Role/password/Vault creation is external platform control, never application migration/source.
|
||||||
|
6. `K101-REQ-06`: N-1 single-URL compatibility, rollout/rollback, Vault ownership/rotation/redaction, CI, installer, compose/Portainer, observability, and deployment handoffs SHALL be separately bounded one-card/one-PR work. Prepared slices remain inactive while current owner-runtime deployments stay N-1; Mosaic control plane/Jason alone authorizes one final atomic activation or rollback, with no force-on-red/bypass. KBN-101 planning itself SHALL not mutate production.
|
||||||
|
7. `K101-REQ-07`: KBN-100 SHALL begin only after the KBN-101 foundation role/schema-boundary certificate; it SHALL rebase on that main head, restore generated Drizzle declaration/snapshot/journal consistency, and bound procedural immutable-table grant/trigger/backfill additions to its schema slice. KBN-101 real deployed-role immutable-operation certification SHALL complete after KBN-100 creates those relations and before KBN-105.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-K101-01`: DTO/command-matrix tests prove required modes, PGlite exception, `mosaic-db-migrator --help|--run|--verify`/stable exits/argv refusal, public-import negative, every finite classified DDL/static-bypass inventory path and both harness pairs reject `DATABASE_URL`-only before connection/DDL, no migration-to-runtime fallback, and `db:push` refusal outside an allowlisted disposable DB. Before inventory, ownership, or status masking, the semantic fixture fails README's exact former commented code-fence generic-wrapper form and the user guide's exact former executable generic-wrapper form; source-consistency proves current `packages/storage/src/cli.ts` directly `execSync`s `pnpm --filter @mosaicstack/db db:migrate` and no `mosaic-db-migrator` bin exists, so runner-delegation documentation fails. The active `docs/guides/migrate-tier.md` route is inventoried to KBN-101-07 and proves runner-produced `--target-url-file /run/secrets/mosaic-migrate-target-url`, fixed paired provider-version file, and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing/private-key isolation; Vault KV-v2 same-response version provenance, separate immutable generation mounts, importer CA, JCS/Ed25519 signature/key rotation/revocation, atomic artifact, expiry/replay, safe-fd secret-version/digest, canonical TLS/CA/server/database/role/manifest/schema bindings, dedicated non-DDL importer, consumer isolation/no log-oracle, and exact no-connection versus zero-DML rejection for missing/wrong/stale/replayed/tampered/wrong-key/substituted/generation-mismatched inputs. The full current non-normative docs inventory—including user guide, federation historical task/MILESTONES status, and non-operative SETUP—has an exact safe disposition. Scanner semantic checks reject automatic first-boot/startup extension/schema/migration wording, Compose-up-before-runner, init-script authority, production `.env`/monorepo auto-load/`EnvironmentFile=`/credential-export-or-argv/restart-as-secret-activation routes, and every unqualified operator-document `mosaic-db-migrator --run|--verify` hit regardless of named/normative/status classification. The exact former README/dev/deployment Compose-first sequences, former SETUP wording, exact former MILESTONES wording `pgvector extension installed + verified on startup`, former architecture-plan/PERFORMANCE/backlog runner routes, and any unqualified runner fixture fail before inventory masking. Only one `Held future procedure` Markdown section—bounded through the next equal-or-higher heading—may contain the explicit non-operative/no-current-command-authority form that names KBN-101-00/-03/-05 and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness; every runner hit outside that section fails. The README assertion for the checked-in direct CI `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` passes only as active legacy N-1, uncertified, non-authorizing-as-an-operator-route status against an isolated disposable CI database pending KBN-101-06 removal—not as an ordinary operator or approved DDL-authority route. Only local PGlite data-layer work or non-PostgreSQL Compose is current (Gateway/Web local startup is held pending daemon/inherited/project-DSN rejection).
|
||||||
|
2. `AC-K101-02`: Fixed namespace lock contention/crash/readiness/non-interference and exact manifest-v1 reconciliation tests prove no replica race/runtime auto-migration and fail closed on every missing/unknown/duplicate/ambiguous/corrupt/stale ledger state.
|
||||||
|
3. `AC-K101-03`: Actual PostgreSQL 17 + pgvector 0.8.2 control-file, catalog, Drizzle-generation, vector-query/operator, fresh/approved-owner/legacy-shadow/partial/resume/rollback/N-1, and real deployed-role tests prove `trusted` absent/untrusted plus relocatability, external-superuser `SET ROLE` create/update/`RESET ROLE` audit, exact `rolcanlogin=false`/`rolsuper=true`/zero-membership/no-runtime-secret state, platform/schema/extension-owner/migrator/importer/runtime separation, `pg_extension.extowner` plus owner-bearing extension-member/schema/version assertions, and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial. They also prove `pg_catalog,mosaic` per-session pool safety, `mosaic_extensions` qualification, identifier injection denial, ownership/membership/ledger-read/TEMP/default grants, and unsafe privilege denial.
|
||||||
|
4. `AC-K101-04`: Disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus for both pairs missing CA/wrong CA/wrong SAN/sslmode downgrade, server/Gateway key mode, UID/GID, secret-consumer isolation, and legacy-drain/`hostssl` negatives prove server bootstrap, ordering, and readiness; PGlite is expressly excluded from this PostgreSQL evidence.
|
||||||
|
5. `AC-K101-05`: Real runtime-role evidence proves INSERT/SELECT succeeds and UPDATE/DELETE fails for every frozen immutable KBN relation.
|
||||||
|
6. `AC-K101-06`: N-1/atomic activation/rollback, Vault/CA-overlap rotation/redaction, health/operator behavior, CI/deployment handoff, independent exact-head security review, and terminal-green CI evidence the foundation before KBN-100; after KBN-100, the real deployed-role immutable-operation certificate and Ultron approval release KBN-105.
|
||||||
|
|
||||||
|
**Normative implementation contract:** [`docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`](./native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md). `ASSUMPTION:` existing `standalone` and `federated` are all PostgreSQL production-like modes; any new PostgreSQL tier inherits these requirements until an explicit versioned amendment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tess Interaction Agent Workstream (TESS)
|
## Tess Interaction Agent Workstream (TESS)
|
||||||
|
|
||||||
### Problem and Objective
|
### Problem and Objective
|
||||||
@@ -175,6 +343,100 @@ Delivery uses five gated milestones: runtime contracts/security; Pi service/stat
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Official Channel Plugin Workstream (#756)
|
||||||
|
|
||||||
|
### Problem and Objective
|
||||||
|
|
||||||
|
The Discord plugin currently couples Discord event handling, gateway bridging, and reply routing in one implementation and activates only on mentions. Mosaic needs an official channel adapter that behaves the same no matter whether the bound logical agent currently runs through Claude, Codex, Pi, OpenCode, or a future harness. The Discord connection and conversation address must remain stable while the gateway changes the runtime provider behind that logical session.
|
||||||
|
|
||||||
|
The objective is to make Discord the first implementation of a transport-neutral official channel contract, with explicit authorization and deterministic channel/thread routing that future Matrix, Slack, and other adapters can share.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
#### In Scope
|
||||||
|
|
||||||
|
1. `CHN-001`: Transport-neutral channel adapter, route, message, attachment, authorization-principal, response-target, and health contracts in `@mosaicstack/types`, including trusted per-binding logical-agent configuration selection.
|
||||||
|
2. `CHN-002`: Stable channel conversation addresses based on logical agent plus channel/thread identity; harness, model, and runtime-provider IDs are forbidden from channel session keys.
|
||||||
|
3. `DSC-001`: An authorized untagged message in a configured agent-bound channel routes to the agent and receives its response in that channel.
|
||||||
|
4. `DSC-002`: A bot mention in a configured parent channel creates a Discord thread, or reuses the thread already attached to that same native message; the mentioned turn and subsequent thread turns route and respond in that thread.
|
||||||
|
5. `DSC-003`: A message already inside an authorized thread inherits authorization from its configured parent and never attempts a nested thread.
|
||||||
|
6. `DSC-004`: Guild, parent channel, user, pairing, and role authorization remains default-deny before thread creation or gateway dispatch.
|
||||||
|
7. `DSC-005`: Discord service authentication, HMAC envelope integrity, replay protection, attachments, approvals, response chunking, and correlation behavior remain intact.
|
||||||
|
8. `DSC-006`: The Discord adapter exposes lifecycle and health behavior through the shared channel contract without importing a harness SDK.
|
||||||
|
|
||||||
|
#### Out of Scope
|
||||||
|
|
||||||
|
1. The logical-agent lease, fencing epoch, execution grant, checkpoint, or cross-harness takeover implementation tracked by #754/#755.
|
||||||
|
2. Dynamic Discord authorization administration in the web UI.
|
||||||
|
3. Multi-guild tenant isolation, DMs, slash commands, voice, reactions, or production bot deployment.
|
||||||
|
4. Implementing Matrix or Slack adapters in this slice.
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
1. **Security:** no thread or dispatch side effect occurs until guild, parent channel, user, pairing, role, and bounded per-user/channel rate checks pass; attachment metadata is shape- and size-bounded; credentials never enter source, messages, session keys, or logs.
|
||||||
|
2. **Portability:** channel contracts and stable conversation IDs contain no Claude, Codex, Pi, OpenCode, model, process, or provider-specific field; each configuration-owned binding selects its trusted logical agent without changing the channel identity.
|
||||||
|
3. **Reliability:** repeated messages for one channel/thread resolve the same conversation handle; reconnecting the adapter does not require a harness-specific rebinding.
|
||||||
|
4. **Maintainability:** Discord-specific API translation stays in the Discord package; gateway and future adapters depend on transport-neutral contracts.
|
||||||
|
5. **Observability:** thread creation or routing failure is reported without message content or credential material.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
1. `AC-CHN-01`: Contract and behavior tests prove the plugin route contains only logical agent plus channel/thread identity and produces the same stable conversation handle regardless of underlying harness selection.
|
||||||
|
2. `AC-CHN-02`: A mentioned authorized parent-channel message creates a thread (or reuses its already-attached thread), dispatches to the thread conversation, and targets the response to that thread.
|
||||||
|
3. `AC-CHN-03`: An untagged authorized parent-channel message dispatches to the parent conversation and targets the response to the parent channel.
|
||||||
|
4. `AC-CHN-04`: Untagged follow-ups inside an authorized thread dispatch and respond in that same thread without creating a nested thread.
|
||||||
|
5. `AC-CHN-05`: Unauthorized guilds, channels, users, unpaired users, insufficient roles, and rate-limited senders produce no thread and no gateway dispatch.
|
||||||
|
6. `AC-CHN-06`: Shared channel contracts are exported from `@mosaicstack/types`, Discord implements the lifecycle/health seam, and no harness SDK is imported by the plugin.
|
||||||
|
7. `AC-CHN-07`: Focused routing/auth tests, package tests, typecheck, lint, formatting, coverage, independent code/security review, and terminal-green CI pass.
|
||||||
|
|
||||||
|
### Constraints, Risks, and Assumptions
|
||||||
|
|
||||||
|
- Dependency: Mosaic gateway remains the policy, durable-session, audit, and runtime-provider boundary.
|
||||||
|
- Constraint: This work must not modify orchestrator-to-Pi migration or #754/#755 lease/fencing files.
|
||||||
|
- Risk: accepting untagged messages could create noisy or unintended agent input. Mitigation: only explicitly configured channels and paired, role-authorized users are accepted, with bounded per-user/channel message and thread rates.
|
||||||
|
- Risk: Discord thread creation can fail because of channel permissions, archived state, or API rate limits. Mitigation: fail without dispatching a turn whose response destination cannot be honored, and emit sanitized diagnostics.
|
||||||
|
- `ASSUMPTION:` Configured channels are dedicated agent interaction surfaces, so authorized untagged human messages are intentional agent input.
|
||||||
|
- `ASSUMPTION:` Mention in a parent channel selects a public thread; messages already in a thread remain there because Discord has no nested threads.
|
||||||
|
- `ASSUMPTION:` One Discord bot may serve multiple configuration-owned logical-agent bindings.
|
||||||
|
- `ASSUMPTION:` Static allowlists and paired-user roles are the authorization administration surface for this slice.
|
||||||
|
|
||||||
|
### Testing and Delivery Intent
|
||||||
|
|
||||||
|
Use TDD for remote-ingress routing and permission boundaries. Required evidence includes parent-channel mention, untagged parent message, existing-thread follow-up, existing-thread mention, thread reuse, unauthorized side-effect denial, stable harness-neutral conversation identity, adapter health, and regression coverage for signed envelopes and approvals. Deliver through issue #756, a reviewed squash PR to `main`, terminal-green CI, and issue closure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mos Runtime Portability Workstream (MOS-PORT)
|
||||||
|
|
||||||
|
### Problem and Objective
|
||||||
|
|
||||||
|
Mos is currently identified partly by a harness-native session and communication process. Replacement/rebinding exists, but no gateway-enforced logical identity or fencing prevents a stale harness from continuing to reply or execute effects after takeover.
|
||||||
|
|
||||||
|
The objective is to make Mos a server-derived logical Mosaic identity whose authority can move safely among runtime connectors. The gateway owns identity, lease, policy, and audit; harnesses remain replaceable adapters.
|
||||||
|
|
||||||
|
### M1 Requirements
|
||||||
|
|
||||||
|
1. `MOS-PORT-ID-001`: Define a normalized logical-agent identity independent of Claude Code, Pi, Codex, tmux, Matrix, and provider-native session IDs.
|
||||||
|
2. `MOS-PORT-LEASE-001`: Persist one exclusive connector lease per tenant/logical-agent/binding with CAS acquisition, monotonic fencing epoch, TTL, heartbeat, explicit release, and takeover.
|
||||||
|
3. `MOS-PORT-FENCE-001`: Bind every connector dispatch/execution grant to the current server-derived tenant, logical identity, binding, connector, scopes, expiry, and lease epoch.
|
||||||
|
4. `MOS-PORT-FENCE-002`: Reject and audit stale, expired, forged, cross-tenant, cross-binding, and unauthorized grants before connector, channel, provider, or tool side effects.
|
||||||
|
5. `MOS-PORT-OBS-001`: Emit credential-safe correlation/audit events for lease acquire, renew, takeover, reject, release, and expiry.
|
||||||
|
6. `MOS-PORT-ARCH-001`: Runtime/provider adapters consume normalized lease context without adding harness-native schemas to Mosaic core.
|
||||||
|
|
||||||
|
### M1 Acceptance Criteria
|
||||||
|
|
||||||
|
1. `AC-MOS-PORT-01`: Two contenders for one binding cannot simultaneously hold current authority under concurrency.
|
||||||
|
2. `AC-MOS-PORT-02`: Successful takeover increments the fencing epoch and every operation from the old epoch fails closed before side effects.
|
||||||
|
3. `AC-MOS-PORT-03`: Gateway/database restart preserves lease and epoch state; expired leases can be recovered only through the authorized takeover path.
|
||||||
|
4. `AC-MOS-PORT-04`: Cross-tenant, cross-agent, cross-binding, forged, and expired lease/grant cases are denied and audited.
|
||||||
|
5. `AC-MOS-PORT-05`: Unit, migration, repository close/reopen, concurrency, abuse, gateway integration, independent security review, CI, and documentation gates pass.
|
||||||
|
|
||||||
|
### Deferred to Later #754 Milestones
|
||||||
|
|
||||||
|
Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete Claude/Pi/Codex adapters, channel cutover, and full cross-harness failover/rollback E2E are explicitly out of M1 scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### High-Level System Diagram
|
### High-Level System Diagram
|
||||||
@@ -529,7 +791,8 @@ Discord remote control channel. Architecture inspired by OpenClaw (https://githu
|
|||||||
- Single-guild binding only (v0.1.0) — prevents data leaks between servers
|
- Single-guild binding only (v0.1.0) — prevents data leaks between servers
|
||||||
- Receives Discord messages, dispatches through gateway routing
|
- Receives Discord messages, dispatches through gateway routing
|
||||||
- Streams agent responses back to Discord (chunked for 2000-char limit)
|
- Streams agent responses back to Discord (chunked for 2000-char limit)
|
||||||
- Supports mention-based activation, thread management for multi-turn
|
- Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics
|
||||||
|
- Uses stable logical-agent/channel conversation addresses independent of the active harness/provider
|
||||||
- Bot pairing and permission management (Discord user → Mosaic user mapping)
|
- Bot pairing and permission management (Discord user → Mosaic user mapping)
|
||||||
- DM support for private conversations
|
- DM support for private conversations
|
||||||
|
|
||||||
@@ -702,10 +965,12 @@ Telegram remote control channel.
|
|||||||
|
|
||||||
### FR-9: Remote Control — Discord
|
### FR-9: Remote Control — Discord
|
||||||
|
|
||||||
- Discord bot that connects to the gateway
|
- Discord bot that connects to the gateway through a transport-neutral channel adapter contract
|
||||||
- Mention-based activation in channels
|
- Authorized messages in configured agent-bound channels work without a mention and respond in-channel
|
||||||
|
- Mentions in parent channels create threads, or reuse a thread already attached to that same native message, for multi-turn conversations
|
||||||
|
- Messages already in a thread remain there without requiring repeated mentions
|
||||||
|
- Stable logical-agent/channel conversation identity survives underlying harness/provider changes
|
||||||
- DM support for private conversations
|
- DM support for private conversations
|
||||||
- Thread creation for multi-turn conversations
|
|
||||||
- Chunked message delivery (Discord 2000-char limit)
|
- Chunked message delivery (Discord 2000-char limit)
|
||||||
- Bot configuration via web dashboard
|
- Bot configuration via web dashboard
|
||||||
- Permission management (which Discord users/roles can interact)
|
- Permission management (which Discord users/roles can interact)
|
||||||
@@ -889,10 +1154,13 @@ Telegram remote control channel.
|
|||||||
|
|
||||||
### AC-3: Discord Remote Control
|
### AC-3: Discord Remote Control
|
||||||
|
|
||||||
- [ ] Discord bot connects and responds to mentions
|
- [ ] Discord bot connects through the harness-neutral channel contract
|
||||||
- [ ] Messages route through gateway to agent pool
|
- [ ] Authorized untagged channel messages route through the gateway and respond in-channel
|
||||||
|
- [ ] Mentioned parent-channel messages create a thread (or reuse their already-attached thread) and respond there
|
||||||
|
- [ ] Existing-thread follow-ups stay in the thread without repeated mentions
|
||||||
|
- [ ] Channel/session identity remains stable while the underlying harness/provider changes
|
||||||
- [ ] Responses stream back to Discord (chunked)
|
- [ ] Responses stream back to Discord (chunked)
|
||||||
- [ ] Thread creation for multi-turn conversations
|
- [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message
|
||||||
|
|
||||||
### AC-4: Gateway Orchestration
|
### AC-4: Gateway Orchestration
|
||||||
|
|
||||||
@@ -936,10 +1204,10 @@ Telegram remote control channel.
|
|||||||
|
|
||||||
### AC-10: Deployment
|
### AC-10: Deployment
|
||||||
|
|
||||||
- [ ] `docker compose up` starts full stack from clean state
|
- [ ] PGlite data-layer work uses no PostgreSQL; optional Compose services are selected individually and do not start PostgreSQL; Gateway/Web local start remains held until KBN-101-02 rejects daemon/inherited/project DSNs before connection or DDL
|
||||||
- [ ] `mosaic` CLI installable and functional on bare metal
|
- [ ] PostgreSQL/federated activation is unavailable until KBN-101-00/-03/-05 deliver external bootstrap, TLS/roles, runner `--run`, runner `--verify`, and Gateway/Compose readiness in that order
|
||||||
- [ ] Database migrations run automatically on first start
|
- [ ] `mosaic` CLI installable and functional on bare metal after the reviewed KBN-101-05 secret-renderer/process-exec or `LoadCredential` interface exists
|
||||||
- [ ] `.env.example` documents all required configuration
|
- [ ] Local-only configuration documentation is distinct from production generation-pinned Vault-rendered consumer material
|
||||||
|
|
||||||
### AC-11: @mosaicstack/\* Packages
|
### AC-11: @mosaicstack/\* Packages
|
||||||
|
|
||||||
@@ -1091,7 +1359,7 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
|||||||
|
|
||||||
6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters.
|
6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters.
|
||||||
|
|
||||||
7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — DM support, mention-based channel activation, thread management, chunked responses. Single guild binding to prevent data leaks between servers. Advanced features (voice, components, slash commands, multi-guild) are post-beta. Rationale: Proven pattern from OpenClaw; ship core interaction first; data isolation is non-negotiable.
|
7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — explicitly configured agent-bound channels accept authorized untagged messages in-channel, while mentions create threads or reuse a thread already attached to that same native message; responses are chunked. Single guild binding prevents data leaks between servers. DM support, voice, components, slash commands, and multi-guild operation are post-beta. Rationale: Ship the requested core interaction model while preserving default-deny data isolation.
|
||||||
|
|
||||||
8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel.
|
8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel.
|
||||||
|
|
||||||
|
|||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
# Documentation Sitemap
|
||||||
|
|
||||||
|
## Compaction refresh lease broker
|
||||||
|
|
||||||
|
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
||||||
|
- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, constrained recovery, fail-closed posture, distinct-principal deployment, and residual risk.
|
||||||
|
- [Constrained recovery skill](../packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md) — source-resident thin wrapper, receipt scope, C4 replay boundary, and T-C middle-drop disclosure.
|
||||||
|
- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements.
|
||||||
|
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||||
|
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
||||||
|
|
||||||
|
## CLI and skill management
|
||||||
|
|
||||||
|
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||||
|
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
||||||
|
|
||||||
|
## Fleet configuration management
|
||||||
|
|
||||||
|
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
||||||
|
- [Desired, derived, and observed state](fleet/concepts/desired-vs-observed-state.md) — roster authority, generation, ownership, and drift.
|
||||||
|
- [Identity, class, and runtime](fleet/concepts/identity-class-runtime.md) — stable name, display alias, class, runtime, provider, and model separation.
|
||||||
|
- [Role authority and leases](fleet/concepts/role-authority-and-leases.md) — validator/merge-gate separation and bounded lease authority.
|
||||||
|
- [Generated launch chain](fleet/concepts/generated-env-launch-chain.md) — strict data parsing, precedence, and quarantine.
|
||||||
|
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — schema, supported values, required fields, defaults, and constraints.
|
||||||
|
- [Fleet CLI reference](fleet/reference/cli.md) — local desired-state commands, JSON/exit behavior, and gateway-catalog separation.
|
||||||
|
- [Lifecycle transitions](fleet/reference/lifecycle-transitions.md) — create/apply/reboot/migration/rollback boundaries.
|
||||||
|
- [Status and drift](fleet/reference/status-and-drift.md) — desired/managed/observed state and current/future classifications.
|
||||||
|
- [Safe agent CRUD](fleet/how-to/create-update-delete-agent.md) — expected generation, dry-run, and partial-failure recovery.
|
||||||
|
- [Local lifecycle operations](fleet/how-to/start-stop-restart.md) — persisted versus one-shot actions.
|
||||||
|
- [Configurable interaction instance](fleet/how-to/configure-tess-interaction.md) and [validator instance](fleet/how-to/configure-ultron-validator.md) — generic identities and protected limits.
|
||||||
|
- [Reconcile and recover](fleet/operations/reconcile-and-recover.md) — plan/apply lock and recovery behavior.
|
||||||
|
- [Environment quarantine](fleet/operations/env-quarantine.md) — private evidence and value-free diagnostics.
|
||||||
|
- [Systemd/tmux troubleshooting](fleet/operations/systemd-tmux-troubleshooting.md) — socket, holder, unmanaged-session, and lock decisions.
|
||||||
|
- [Backup/restore boundary](fleet/operations/backup-restore.md) and [upgrade-assets hold](fleet/operations/upgrade-assets.md).
|
||||||
|
- [v1-to-v2 migration preview](fleet/migration/v1-to-v2.md) and [executable artifact dispositions](fleet/migration/example-profile-disposition.md).
|
||||||
|
- [FCM M5 closure evidence](reports/documentation/758-fleet-config-ia-closure.md) and [approved deferrals](reports/deferred/758-fleet-config-deferrals.md).
|
||||||
|
|
||||||
|
## Official channel plugins
|
||||||
|
|
||||||
|
- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts.
|
||||||
|
- [Discord administrator configuration](guides/admin-guide.md#discord-ingress-security) — secrets, allowlists, bindings, role policy, and thread permissions.
|
||||||
|
- [Discord user workflow](tess/USER-GUIDE.md#discord-conversations) — in-channel messages, mention-created threads, and runtime-transparent continuity.
|
||||||
|
- [Channel plugin authoring](tess/PLUGIN-GUIDE.md#official-channel-adapter-contract) — requirements for future Matrix, Slack, and other official adapters.
|
||||||
|
- [Discord package guide](../plugins/discord/README.md) — package behavior, configuration shape, and development commands.
|
||||||
|
|
||||||
|
## Native Kanban and canonical task SOT
|
||||||
|
|
||||||
|
- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0–P3 requirements and acceptance criteria.
|
||||||
|
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
|
||||||
|
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
|
||||||
|
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
|
||||||
|
- [KBN-101 database role split](native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) — rc.16 direct-Drizzle storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README/user-guide wrapper forms fail before masking and source-consistency rejects runner-delegation copy; held bootstrap → TLS/roles → run → verify → readiness; plus prior attestation, pgvector owner, classifier, TLS, activation, and certification prerequisite.
|
||||||
|
- [Federated tier data migration](guides/migrate-tier.md) — active KBN-101-07 operator route: runner-produced target attestation, dedicated non-DDL importer, and paired credential-/attestation-file references only.
|
||||||
|
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
|
||||||
|
- [KBN-101 exact-head security review](reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) — retained prior REQUEST CHANGES evidence for `da742ca`; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding.
|
||||||
|
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft.
|
||||||
|
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
|
||||||
|
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.
|
||||||
|
|
||||||
|
## Tess interaction agent
|
||||||
|
|
||||||
|
### Operator guides
|
||||||
|
|
||||||
|
- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows.
|
||||||
|
- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls.
|
||||||
|
- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow.
|
||||||
|
- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements.
|
||||||
|
- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures.
|
||||||
|
|
||||||
|
### Architecture and security
|
||||||
|
|
||||||
|
- [Architecture](tess/ARCHITECTURE.md)
|
||||||
|
- [Threat model](tess/THREAT-MODEL.md)
|
||||||
|
- [Mos coordination boundary](tess/MOS-COORDINATION.md)
|
||||||
|
- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md)
|
||||||
|
- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md)
|
||||||
|
|
||||||
|
### API contract
|
||||||
|
|
||||||
|
- [Tess OpenAPI contract](openapi-tess.yaml)
|
||||||
|
|
||||||
|
### Migration and qualification
|
||||||
|
|
||||||
|
- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md)
|
||||||
|
- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md)
|
||||||
|
- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md)
|
||||||
|
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
|
||||||
|
- [Verification matrix](tess/VERIFICATION-MATRIX.md)
|
||||||
|
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
|
||||||
|
- [Independent Option 2 runtime-portability qualification (2026-07-14)](tess/qualification/2026-07-14-option2-runtime-portability.md)
|
||||||
|
|
||||||
|
## Runtime-neutral Mos portability
|
||||||
|
|
||||||
|
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies.
|
||||||
|
- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754)
|
||||||
|
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
|
||||||
|
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
|
||||||
|
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
|
||||||
|
|
||||||
|
## Comms evolution — Matrix-native MACP (design, draft)
|
||||||
|
|
||||||
|
- [RFC-001 — MACP: a Mosaic-native, Matrix-native comms layer](rfcs/RFC-001-MACP-MATRIX-NATIVE.md) — Synapse + Mosaic appservice backbone, MACP v1 protocol, presence/escalation, federation, strangler migration off the Hermes MCP bridge.
|
||||||
|
- [RFC-002 — Install, configuration & topology for the Matrix/MACP comms system](rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md) — open-source install topology modes, ACME cert provisioning, pluggable secret backend, and config precedence.
|
||||||
+27
-1
@@ -15,9 +15,11 @@
|
|||||||
## Workstream Rollup
|
## Workstream Rollup
|
||||||
|
|
||||||
| id | status | workstream | progress | tasks file | notes |
|
| id | status | workstream | progress | tasks file | notes |
|
||||||
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
| --- | ----------------- | ------------------------------ | ---------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
||||||
|
| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges |
|
||||||
|
| W4 | planning-complete | Fleet configuration management | 0 / 12 cards | This file (§ Fleet configuration management #758) | Issue #758; M0 docs gate defines the implementation DAG before any fleet mutation |
|
||||||
|
|
||||||
## Cross-Cutting Tracking
|
## Cross-Cutting Tracking
|
||||||
|
|
||||||
@@ -41,6 +43,30 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
||||||
3. Follow per-task agent + tier guidance from the workstream manifest
|
3. Follow per-task agent + tier guidance from the workstream manifest
|
||||||
|
|
||||||
|
## Fleet configuration management (#758) — M0–M5 implementation DAG
|
||||||
|
|
||||||
|
> **PRD:** [Fleet declarative configuration management](./PRD.md#fleet-declarative-configuration-management-workstream-fcm-758) · **M0 acceptance:** [docs IA checklist](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) · **baseline dispositions:** [legacy example/profile inventory](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md)
|
||||||
|
>
|
||||||
|
> Every row below is one independently reviewable card and **one PR**. `depends_on` is a
|
||||||
|
> hard DAG edge; no card may silently absorb another card's scope. All source cards require
|
||||||
|
> the repository quality gates, independent code and security review, terminal-green CI, and
|
||||||
|
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||||
|
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
|
||||||
|
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
||||||
|
| FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
|
||||||
|
| FCM-M1-003 | done | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | #770 squash `e9c4aa3`; shipped artifact disposition validation delivered |
|
||||||
|
| FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered |
|
||||||
|
| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
|
||||||
|
| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
|
||||||
|
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
||||||
|
| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
|
||||||
|
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
|
||||||
|
| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 content squash 627cf2bb; de-flake repair PR#851/#849 squash 77c9a826; completion proof wp1937 @aa999daf push/ci step 49632 recovery_runtime_unittest.py 3/3 OK (closes wp1932 step 49576 Errno111) |
|
||||||
|
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
|
||||||
|
|
||||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||||
|
|
||||||
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# ADR: Optional AI egress gateways for runtime-neutral Mos
|
||||||
|
|
||||||
|
**Status:** Proposed for controlled prototypes; not approved as Mosaic core
|
||||||
|
|
||||||
|
**Date:** 2026-07-14
|
||||||
|
|
||||||
|
**Issues:** #754, #755
|
||||||
|
|
||||||
|
**Decision owner:** Mosaic Gateway / provider-adapter architecture
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The emergency Mos continuity path kept Claude Code as the harness and translated Anthropic Messages traffic to Codex OAuth through a small localhost proxy. That preserved the existing Claude Discord plugin and transcript, but exposed two architectural facts:
|
||||||
|
|
||||||
|
1. Harness identity, channel entitlement, provider credentials, and inference transport are separate concerns.
|
||||||
|
2. A generic AI gateway can improve provider routing, budgets, and observability, but must not become Mosaic's identity, authorization, tenant, or orchestration boundary.
|
||||||
|
|
||||||
|
The Tess qualification report also found that current provider rebinding is not identity-continuous failover. Mosaic still needs a logical agent identity, durable connector lease/fencing, canonical handoff/checkpoint, exactly-once receipts, concrete harness adapters, and cross-harness rollback E2E.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Mosaic MAY support LiteLLM, Bifrost, the purpose-built Claude/Codex proxy, or future gateways as optional egress implementations behind `IProviderAdapter` / `AgentRuntimeProvider`.
|
||||||
|
|
||||||
|
Mosaic Gateway remains authoritative for:
|
||||||
|
|
||||||
|
- authenticated actor and tenant identity;
|
||||||
|
- logical agent identity and connector binding;
|
||||||
|
- authorization, approval, and policy;
|
||||||
|
- lease epoch and stale-holder fencing;
|
||||||
|
- audit correlation and redaction;
|
||||||
|
- canonical handoff/checkpoint state;
|
||||||
|
- idempotency and side-effect receipts.
|
||||||
|
|
||||||
|
An egress gateway MUST NOT:
|
||||||
|
|
||||||
|
- receive channel ingress directly;
|
||||||
|
- authorize tools or connector ownership;
|
||||||
|
- define Mosaic tenant or agent identity;
|
||||||
|
- persist raw Mosaic handoffs or channel credentials;
|
||||||
|
- bypass adapter capability negotiation;
|
||||||
|
- silently fail over when policy, lease, or provider health is uncertain.
|
||||||
|
|
||||||
|
Allowed topology:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Discord / Matrix / CLI / web
|
||||||
|
↓
|
||||||
|
Mosaic Gateway: identity, authz, lease/fence, approvals, audit
|
||||||
|
↓
|
||||||
|
IProviderAdapter / AgentRuntimeProvider
|
||||||
|
↓
|
||||||
|
optional egress gateway
|
||||||
|
↓
|
||||||
|
upstream provider or subscription-backed OAuth session
|
||||||
|
```
|
||||||
|
|
||||||
|
## Candidate assessment
|
||||||
|
|
||||||
|
### Purpose-built `raine/claude-code-proxy`
|
||||||
|
|
||||||
|
**Disposition:** Approved only for the verified emergency localhost bridge.
|
||||||
|
|
||||||
|
Strengths:
|
||||||
|
|
||||||
|
- explicit Codex device OAuth flow;
|
||||||
|
- small operational surface;
|
||||||
|
- Anthropic Messages translation suitable for Claude Code;
|
||||||
|
- model and reasoning-effort enforcement;
|
||||||
|
- straightforward loopback systemd supervision and rollback.
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
- not a Mosaic multi-tenant control plane;
|
||||||
|
- Claude built-in channels still depend on Claude subscription entitlement and feature lookup;
|
||||||
|
- model aliases can obscure the upstream model unless proxy policy/logs are treated as evidence;
|
||||||
|
- no replacement for connector leasing, canonical handoff, or exactly-once effects.
|
||||||
|
|
||||||
|
### LiteLLM
|
||||||
|
|
||||||
|
**Disposition:** Candidate for a formal adapter-only prototype and terms/security review.
|
||||||
|
|
||||||
|
Current documentation states that ChatGPT subscription access is available through an OAuth device-code flow. LiteLLM also provides broad provider routing, virtual keys, budgets, observability, and OpenAI/Anthropic-compatible surfaces.
|
||||||
|
|
||||||
|
Required prototype gates:
|
||||||
|
|
||||||
|
- verify the exact ChatGPT subscription OAuth flow and supported models against current provider terms;
|
||||||
|
- document token location, encryption, revocation, refresh, scope, and incident response;
|
||||||
|
- prove tenant isolation and prevent virtual keys from becoming Mosaic principals;
|
||||||
|
- verify streaming, tool calls, reasoning controls, cancellation, and idempotency metadata;
|
||||||
|
- fail closed instead of selecting an unhealthy provider merely to return a result;
|
||||||
|
- demonstrate that Mosaic audit correlation survives gateway retries/failover;
|
||||||
|
- keep channel ingress and connector credentials outside LiteLLM.
|
||||||
|
|
||||||
|
Source references:
|
||||||
|
|
||||||
|
- [LiteLLM ChatGPT subscription provider](https://docs.litellm.ai/docs/providers/chatgpt)
|
||||||
|
- [LiteLLM providers](https://docs.litellm.ai/docs/providers)
|
||||||
|
|
||||||
|
### Bifrost
|
||||||
|
|
||||||
|
**Disposition:** Candidate for governance/routing research; subscription OAuth compatibility unverified.
|
||||||
|
|
||||||
|
Useful concepts include virtual keys, budgets, rate limits, weighted load balancing, and automatic provider failover. Those features may inform Mosaic egress policy, but Bifrost virtual keys are downstream credentials—not Mosaic actors or tenants.
|
||||||
|
|
||||||
|
Required prototype gates:
|
||||||
|
|
||||||
|
- verify Codex/ChatGPT subscription OAuth rather than assuming API-key compatibility;
|
||||||
|
- map budgets and virtual keys to server-derived Mosaic tenants without duplicating authority;
|
||||||
|
- prove failover does not violate connector lease, approval, or exactly-once semantics;
|
||||||
|
- ensure request/response logs are redacted before persistence;
|
||||||
|
- disable or constrain automatic failover when policy or side-effect state is ambiguous.
|
||||||
|
|
||||||
|
Source references:
|
||||||
|
|
||||||
|
- [Bifrost overview](https://docs.getbifrost.ai/overview)
|
||||||
|
- [Bifrost repository](https://github.com/maximhq/bifrost)
|
||||||
|
|
||||||
|
### `teremterem/claude-code-gpt-5-codex`
|
||||||
|
|
||||||
|
**Disposition:** Not selected as the emergency implementation; useful as a historical LiteLLM recipe.
|
||||||
|
|
||||||
|
The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated Claude users to log out, and documents a Claude Web Search schema incompatibility. Logging Claude out conflicts with the channel-entitlement requirement observed in the live Mos cutover. The repository therefore does not, as provided, satisfy subscription-OAuth plus built-in-channel continuity.
|
||||||
|
|
||||||
|
Source references:
|
||||||
|
|
||||||
|
- [Repository](https://github.com/teremterem/claude-code-gpt-5-codex)
|
||||||
|
- [Environment template](https://github.com/teremterem/claude-code-gpt-5-codex/blob/main/.env.template)
|
||||||
|
|
||||||
|
## Security consequences
|
||||||
|
|
||||||
|
- Subscription OAuth grants are high-value credentials and require the same lifecycle controls as service credentials.
|
||||||
|
- Downstream virtual keys reduce provider-key exposure but do not establish user, tenant, or agent authority.
|
||||||
|
- Automatic retry/failover can duplicate tool or external side effects unless Mosaic owns operation IDs and receipts.
|
||||||
|
- Gateway telemetry can contain prompts, tool schemas, and model output; redaction and retention policy must apply before persistence.
|
||||||
|
- A localhost unauthenticated translation endpoint must remain loopback-only and process-isolated.
|
||||||
|
|
||||||
|
## Acceptance before production use
|
||||||
|
|
||||||
|
1. Threat model and provider-terms review approved.
|
||||||
|
2. Credential lifecycle and revocation drill documented and exercised.
|
||||||
|
3. Adapter contract tests pass for streaming, tools, cancellation, reasoning policy, errors, and audit correlation.
|
||||||
|
4. Tenant-bound authorization remains entirely in Mosaic Gateway.
|
||||||
|
5. Failure injection proves no duplicate side effects across retries or provider failover.
|
||||||
|
6. Rollback to the prior provider path is exercised.
|
||||||
|
7. Independent code and security reviews approve the exact deployed revision.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- #754 owns cross-harness logical identity, checkpoint, receipt, adapter, and failover work.
|
||||||
|
- #755 / PR #757 implements the first logical identity and connector lease/fencing boundary.
|
||||||
|
- A later issue should prototype LiteLLM and Bifrost behind the provider adapter after #755 is merged and independently qualified.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user