Compare commits

..
Author SHA1 Message Date
jarvis 55ae77b6fd feat(#707): secure Discord service ingress
ci/woodpecker/pr/ci Pipeline was successful
2026-07-12 18:00:29 -05:00
654 changed files with 4283 additions and 130720 deletions
-1
View File
@@ -8,7 +8,6 @@ coverage
.env.local
*.tsbuildinfo
.pnpm-store
__pycache__/
docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example
+1 -1
View File
@@ -1 +1 @@
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
pnpm typecheck && pnpm lint && pnpm format:check
+4 -4
View File
@@ -1,5 +1,5 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
# Non-root checkouts use their own HOME. Override without editing this file via
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
store-dir=${HOME}/.local/share/pnpm/store
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
# instead of repopulating a fresh one.
store-dir=/root/.local/share/pnpm/store
-9
View File
@@ -4,15 +4,6 @@ pnpm-lock.yaml
**/node_modules
**/drizzle
**/.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/
docs/tess/TASKS.md
docs/scratchpads/
packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json
-33
View File
@@ -41,32 +41,6 @@ steps:
# (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
# 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:
image: *node_image
@@ -76,7 +50,6 @@ steps:
depends_on:
- install
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck
lint:
@@ -103,12 +76,6 @@ steps:
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands:
- *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.
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- |
+5 -104
View File
@@ -1,5 +1,5 @@
# Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images
# Runs only on main branch push/tag
variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -23,21 +23,9 @@ variables:
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main, next]
- branch: [main]
event: [push, manual, tag]
steps:
@@ -115,84 +103,6 @@ steps:
depends_on:
- build
publish-next-npm:
image: *node_image
# Durable @next integration-line publish. Runs only on next; never writes
# the latest dist-tag and never commits the computed prerelease versions.
when:
- event: [push, manual]
branch: next
environment:
NPM_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
commands:
- *enable_pnpm
- |
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
exit 1
fi
if [ -z "$CI_PIPELINE_NUMBER" ]; then
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
exit 1
fi
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
}
const [, major, minor, patch] = stableMatch;
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
for (const root of roots) walk(root);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
@@ -224,17 +134,8 @@ steps:
- 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}"
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
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
@@ -245,7 +146,7 @@ steps:
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
@@ -271,7 +172,7 @@ steps:
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
+6 -7
View File
@@ -26,14 +26,13 @@ pnpm test # Vitest (all packages)
pnpm build # Build all packages
# Database
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
# init SQL, or Compose PostgreSQL service from this checkout.
pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
pnpm --filter @mosaicstack/db db:generate # Generate migrations
pnpm --filter @mosaicstack/db db:migrate # Run migrations
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
docker compose up -d valkey
# 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.
# Dev
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway
```
## Conventions
+4 -7
View File
@@ -22,13 +22,10 @@
FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the
# postgresql-client used by the test step's pg_isready readiness probe. `bash`,
# `git`, and `jq` are baked here too — framework shell tests and the shipped
# Codex review wrappers require them without per-run installation in ci.yml.
# `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
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
# `apk add bash`.
RUN apk add --no-cache python3 make g++ postgresql-client bash
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare [email protected] --activate
+20 -59
View File
@@ -30,16 +30,6 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@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:
```bash
@@ -107,10 +97,7 @@ mosaic config path # Print config file path
```bash
mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source
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 update # Check for and install CLI updates
mosaic wizard # Full guided setup wizard
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic coord init # Initialize a new orchestration mission
@@ -170,12 +157,7 @@ mosaic storage status
mosaic storage tier
mosaic storage export
mosaic storage import
# 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.
mosaic storage migrate
```
### Telemetry
@@ -210,50 +192,33 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
git clone [email protected]:mosaicstack/stack.git
cd stack
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
# 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.
# Start infrastructure (Postgres, Valkey, Jaeger)
docker compose up -d
# Install dependencies
pnpm install
# Verify dependencies and generated state before running source-quality gates.
# 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
# Run migrations
pnpm --filter @mosaicstack/db run db:migrate
# Optional local queue service only. This does not start PostgreSQL.
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.
# Start all services in dev mode
pnpm dev
```
### Held future procedure
### Infrastructure
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.
Docker Compose provides:
**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`
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
this README—will provide the reviewed commands and secret-consumer interface.
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.
| Service | Port | Purpose |
| --------------------- | --------- | ---------------------- |
| PostgreSQL (pgvector) | 5433 | Primary database |
| Valkey | 6380 | Task queue + caching |
| Jaeger | 16686 | Distributed tracing UI |
| OTEL Collector | 4317/4318 | Telemetry ingestion |
### Quality Gates
```bash
pnpm preflight # Checkout/dependency/generated-state validation
pnpm typecheck # TypeScript type checking (all packages)
pnpm lint # ESLint (all packages)
pnpm test # Vitest (all packages)
@@ -266,7 +231,7 @@ pnpm format # Prettier auto-fix
Woodpecker CI runs on every push:
- `pnpm install --frozen-lockfile`
- **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.
- Database migration against a fresh Postgres
- `pnpm test` (Turbo-orchestrated across all packages)
npm packages are published to the Gitea package registry on main merges.
@@ -371,15 +336,11 @@ The CLI also performs a background update check on every invocation (cached for
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --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 --ref v1.0 # Install from a specific git ref
bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
## Contributing
```bash
-1
View File
@@ -31,7 +31,6 @@
"@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@mosaicstack/agent": "workspace:^",
"@mosaicstack/auth": "workspace:^",
"@mosaicstack/brain": "workspace:^",
"@mosaicstack/config": "workspace:^",
@@ -1,519 +0,0 @@
/**
* Federation M3 single-gateway integration tests (FED-M3-10).
*
* Covers MILESTONES.md M3 acceptance:
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
* - #7: max_rows_per_query caps list results.
*
* Strategy:
* - Real PostgreSQL via @mosaicstack/db.
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
*
* Run:
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
* src/__tests__/integration/federation-m3-list.integration.test.ts
*/
import 'reflect-metadata';
import * as crypto from 'node:crypto';
import type { ExecutionContext } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
and,
createDb,
eq,
federationGrants,
federationPeers,
inArray,
missionTasks,
missions,
projects,
tasks,
teamMembers,
teams,
type Db,
type DbHandle,
users,
} from '@mosaicstack/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
import { FederationScopeService } from '../../federation/server/scope.service.js';
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
import { ListController } from '../../federation/server/verbs/list.controller.js';
import {
makeMosaicIssuedCert,
makeSelfSignedCert,
} from '../../federation/__tests__/helpers/test-cert.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
interface TestIds {
readonly subjectUserId: string;
readonly otherUserId: string;
readonly peerId: string;
readonly revokedPeerId: string;
readonly activeGrantId: string;
readonly revokedGrantId: string;
readonly subjectProjectId: string;
readonly subjectMissionId: string;
readonly otherProjectId: string;
readonly teamId: string;
readonly unauthorizedTeamId: string;
readonly teamProjectId: string;
readonly taskIds: readonly string[];
readonly excludedTaskIds: readonly string[];
readonly subjectNoteId: string;
readonly otherUserNoteId: string;
}
function pemToDer(pem: string): Buffer {
return Buffer.from(
pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, ''),
'base64',
);
}
function makeFederationRequest(certPem: string): FastifyRequest {
return {
raw: {
socket: {
getPeerCertificate: () => ({
raw: pemToDer(certPem),
serialNumber: CERT_SERIAL_HEX,
}),
},
},
} as unknown as FastifyRequest;
}
function makeGuardContext(request: FastifyRequest): {
readonly context: ExecutionContext;
readonly sent: { statusCode?: number; payload?: unknown };
} {
const sent: { statusCode?: number; payload?: unknown } = {};
const reply = {
status: (statusCode: number) => {
sent.statusCode = statusCode;
return {
header: () => ({
send: (payload: unknown) => {
sent.payload = payload;
},
}),
};
},
} as unknown as FastifyReply;
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
return { context, sent };
}
async function insertUser(db: Db, id: string, label: string): Promise<void> {
await db.insert(users).values({
id,
name: `${RUN_ID}-${label}`,
email: `${RUN_ID}-${label}@federation-test.invalid`,
emailVerified: false,
});
}
async function seedFixtures(db: Db): Promise<TestIds> {
const subjectUserId = `${RUN_ID}-subject`;
const otherUserId = `${RUN_ID}-other`;
const peerId = crypto.randomUUID();
const revokedPeerId = crypto.randomUUID();
const activeGrantId = crypto.randomUUID();
const revokedGrantId = crypto.randomUUID();
const subjectProjectId = crypto.randomUUID();
const subjectMissionId = crypto.randomUUID();
const otherProjectId = crypto.randomUUID();
const teamId = crypto.randomUUID();
const unauthorizedTeamId = crypto.randomUUID();
const teamProjectId = crypto.randomUUID();
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
const subjectNoteId = crypto.randomUUID();
const otherUserNoteId = crypto.randomUUID();
await insertUser(db, subjectUserId, 'subject');
await insertUser(db, otherUserId, 'other');
await db.insert(teams).values([
{
id: teamId,
name: `${RUN_ID} allowed team`,
slug: `${RUN_ID}-allowed-team`,
ownerId: subjectUserId,
managerId: subjectUserId,
},
{
id: unauthorizedTeamId,
name: `${RUN_ID} unauthorized team`,
slug: `${RUN_ID}-unauthorized-team`,
ownerId: otherUserId,
managerId: otherUserId,
},
]);
await db.insert(teamMembers).values([
{ teamId, userId: subjectUserId, role: 'member' },
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
]);
await db.insert(projects).values([
{
id: subjectProjectId,
name: `${RUN_ID} subject personal project`,
ownerType: 'user',
ownerId: subjectUserId,
},
{
id: otherProjectId,
name: `${RUN_ID} other personal project`,
ownerType: 'user',
ownerId: otherUserId,
},
{
id: teamProjectId,
name: `${RUN_ID} unauthorized team project`,
ownerType: 'team',
teamId: unauthorizedTeamId,
},
]);
await db.insert(missions).values({
id: subjectMissionId,
name: `${RUN_ID} subject mission`,
projectId: subjectProjectId,
userId: subjectUserId,
});
await db.insert(tasks).values([
{
id: taskIds[0],
title: `${RUN_ID} visible task 1`,
missionId: subjectMissionId,
createdAt: new Date('2026-06-25T03:00:00.000Z'),
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
},
{
id: taskIds[1],
title: `${RUN_ID} visible task 2`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T02:00:00.000Z'),
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
},
{
id: taskIds[2],
title: `${RUN_ID} visible task 3`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T01:00:00.000Z'),
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
},
{
id: excludedTaskIds[0],
title: `${RUN_ID} other user task`,
projectId: otherProjectId,
createdAt: new Date('2026-06-25T04:00:00.000Z'),
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
},
{
id: excludedTaskIds[1],
title: `${RUN_ID} unauthorized team task`,
projectId: teamProjectId,
createdAt: new Date('2026-06-25T05:00:00.000Z'),
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
},
]);
await db.insert(missionTasks).values([
{
id: subjectNoteId,
missionId: subjectMissionId,
userId: subjectUserId,
notes: `${RUN_ID} subject visible note`,
createdAt: new Date('2026-06-25T03:30:00.000Z'),
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
},
{
id: otherUserNoteId,
missionId: subjectMissionId,
userId: otherUserId,
notes: `${RUN_ID} other user note on subject mission`,
createdAt: new Date('2026-06-25T04:30:00.000Z'),
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
},
]);
await db.insert(federationPeers).values([
{
id: peerId,
commonName: `${RUN_ID}-active-peer`,
displayName: `${RUN_ID} Active Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
{
id: revokedPeerId,
commonName: `${RUN_ID}-revoked-peer`,
displayName: `${RUN_ID} Revoked Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
]);
await db.insert(federationGrants).values([
{
id: activeGrantId,
peerId,
subjectUserId,
status: 'active',
scope: {
resources: ['tasks', 'notes'],
excluded_resources: [],
filters: {
tasks: { include_personal: true, include_teams: [] },
notes: { include_personal: true, include_teams: [] },
},
max_rows_per_query: 2,
},
},
{
id: revokedGrantId,
peerId,
subjectUserId,
status: 'revoked',
revokedAt: new Date(),
revokedReason: `${RUN_ID} revoked grant fixture`,
scope: {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 2,
},
},
]);
return {
subjectUserId,
otherUserId,
peerId,
revokedPeerId,
activeGrantId,
revokedGrantId,
subjectProjectId,
subjectMissionId,
otherProjectId,
teamId,
unauthorizedTeamId,
teamProjectId,
taskIds,
excludedTaskIds,
subjectNoteId,
otherUserNoteId,
};
}
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
if (!ids) {
return;
}
await db
.delete(missionTasks)
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
.catch(() => {});
await db
.delete(tasks)
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
.catch(() => {});
await db
.delete(missions)
.where(eq(missions.id, ids.subjectMissionId))
.catch(() => {});
await db
.delete(projects)
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
.catch(() => {});
await db
.delete(teamMembers)
.where(
and(
eq(teamMembers.userId, ids.subjectUserId),
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
),
)
.catch(() => {});
await db
.delete(teams)
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
.catch(() => {});
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
.catch(() => {});
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
.catch(() => {});
await db
.delete(users)
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
.catch(() => {});
}
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
let handle: DbHandle;
let db: Db;
let moduleRef: TestingModule;
let guard: FederationAuthGuard;
let listController: ListController;
let ids: TestIds | undefined;
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
ids = await seedFixtures(db);
moduleRef = await Test.createTestingModule({
controllers: [ListController],
providers: [
{ provide: DB, useValue: db },
GrantsService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
}).compile();
guard = moduleRef.get(FederationAuthGuard);
listController = moduleRef.get(ListController);
}, 30_000);
afterAll(async () => {
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
});
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
const malformedOidCert = await makeSelfSignedCert();
const request = makeFederationRequest(malformedOidCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(401);
expect(sent.payload).toMatchObject({
error: {
code: 'unauthorized',
message: expect.stringContaining('missing required OID'),
},
});
expect(request.federationContext).toBeUndefined();
});
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
expect(ids).toBeDefined();
const revokedCert = await makeMosaicIssuedCert({
grantId: ids!.revokedGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(revokedCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(403);
expect(sent.payload).toMatchObject({
error: {
code: 'forbidden',
message: 'Federation access denied',
},
});
expect(request.federationContext).toBeUndefined();
});
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('tasks', request, { limit: 100 });
const returnedIds = response.items.map((item) => item['id']);
expect(response.items).toHaveLength(2);
expect(response._truncated).toBe(true);
expect(response.nextCursor).toEqual(expect.any(String));
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
expect(returnedIds).not.toContain(ids!.taskIds[2]);
for (const excludedId of ids!.excludedTaskIds) {
expect(returnedIds).not.toContain(excludedId);
}
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('excludes another user mission task notes on the same authorized mission', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('notes', request, { limit: 10 });
const returnedIds = response.items.map((item) => item['id']);
expect(returnedIds).toEqual([ids!.subjectNoteId]);
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('fails closed for unsupported list resources', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is not supported',
},
},
status: 403,
});
});
});
@@ -1,194 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
import {
createDiscordIngressEnvelope,
DiscordPlugin,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import { InteractionController } from '../../agent/interaction.controller.js';
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
import { DurableSessionService } from '../../agent/durable-session.service.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
const SERVICE_TOKEN = 'test-discord-service-token';
const envKeys = [
'DISCORD_SERVICE_TOKEN',
'DISCORD_SERVICE_TENANT_ID',
'DISCORD_INTERACTION_BINDINGS',
'DISCORD_ALLOWED_GUILD_IDS',
'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
] as const;
const priorEnv = new Map<string, string | undefined>();
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
return {
content,
messageId,
correlationId,
guildId: 'guild-1',
channelId: 'channel-1',
userId: 'discord-admin-1',
conversationId: 'Nova:discord:channel-1',
};
}
function authorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
{
select: () => ({
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
}),
} as never,
{
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => entries.set(key, value),
del: async (key: string) => Number(entries.delete(key)),
},
);
}
describe('interaction Discord/CLI durable-session integration', () => {
afterEach(() => {
for (const key of envKeys) {
const value = priorEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
priorEnv.clear();
});
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
for (const key of envKeys) priorEnv.set(key, process.env[key]);
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
]);
const durable = new DurableSessionService(
new InMemoryDurableSessionStore() as never,
{} as never,
);
const enrollmentRuntime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const controller = new InteractionController(enrollmentRuntime as never, durable);
await controller.enroll(
'Nova',
'Nova:discord:channel-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
'cli-enrollment-correlation',
);
const authz = authorization();
const terminated = vi.fn().mockResolvedValue(undefined);
const runtime = new RuntimeProviderService(
{
require: () => ({
capabilities: async () => ({ supported: ['session.terminate'] }),
terminate: terminated,
}),
} as never,
{ record: async () => undefined } as never,
{
consume: (approvalId, action) =>
authz.consumeRuntimeTerminationApproval(approvalId, action),
},
);
const gateway = new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
authz,
runtime,
durable,
);
const client = { data: { discordService: true }, emit: vi.fn() };
const plugin = new DiscordPlugin({
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-1'],
allowedChannelIds: ['channel-1'],
allowedUserIds: ['discord-admin-1'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
],
});
const pluginInternals = plugin as unknown as {
client: { user: { id: string } };
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
handleDiscordMessage(message: unknown): void;
};
const pluginSocket = { connected: true, emit: vi.fn() };
pluginInternals.client = { user: { id: 'bot-1' } };
pluginInternals.socket = pluginSocket;
pluginInternals.handleDiscordMessage({
id: 'approve-1',
guildId: 'guild-1',
channelId: 'channel-1',
author: { id: 'discord-admin-1', bot: false },
mentions: { has: () => true },
content: '<@bot-1> /approve',
channel: { parentId: null },
attachments: new Map(),
});
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
success: boolean;
};
expect(approval.success).toBe(true);
await gateway.handleDiscordStop(
client as never,
createDiscordIngressEnvelope(
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
SERVICE_TOKEN,
),
);
expect(terminated).toHaveBeenCalledWith(
'runtime-1',
approval.approvalId,
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
);
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
correlationId: 'discord-stop-correlation',
success: true,
});
});
});
@@ -1,11 +1,9 @@
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -16,9 +14,6 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {}
@Get()
@@ -60,14 +55,6 @@ export class AdminHealthController {
}
private async checkCache(): Promise<ServiceStatusDto> {
// On Local tier there is no Redis. The cache is intentionally absent, which
// is a healthy state for this tier — report 'ok' rather than opening a new
// ioredis connection on every admin health check (which would spam
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
// signals "no cache backend to measure" for this tier.
if (this.mosaicConfig?.queue?.type === 'local') {
return { status: 'ok', latencyMs: 0 };
}
const start = Date.now();
const handle = createQueue();
try {
@@ -12,24 +12,18 @@ type AgentServiceInternals = {
creating: Map<string, Promise<AgentSession>>;
};
function makeService(operatorMemory: unknown = null): AgentService {
function makeService(): AgentService {
return new AgentService(
{
getDefaultModel: vi.fn(() => null),
getRegistry: vi.fn(() => ({})),
findModel: vi.fn(),
listAvailableModels: vi.fn(() => []),
} as never,
{} as never,
{} as never,
{} as never,
{ available: false } as never,
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
{} as never,
{} as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
);
}
@@ -115,18 +109,6 @@ describe('AgentService owner/tenant scope enforcement', () => {
).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
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(
ForbiddenException,
@@ -138,37 +120,6 @@ describe('AgentService owner/tenant scope enforcement', () => {
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
});
it('derives the operator-memory scope on the createSession production path', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
// Session construction reaches the real scope derivation before the intentionally incomplete
// Pi test double rejects later in createAgentSession.
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
tenantId: OWNER_SCOPE.tenantId,
ownerId: OWNER_SCOPE.userId,
sessionId: CONVERSATION_ID,
});
});
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
internals(service).sessions.set(CONVERSATION_ID, makeSession());
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(buildTools).not.toHaveBeenCalled();
expect(plugin.capture).not.toHaveBeenCalled();
expect(plugin.search).not.toHaveBeenCalled();
});
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
const service = makeService();
const session = makeSession();
@@ -1,370 +0,0 @@
import { describe, expect, it } from 'vitest';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import type { ActorTenantScope } from '../../auth/session-scope.js';
import {
RuntimeProviderAuditService,
RuntimeProviderService,
type RuntimeAuditEvent,
type RuntimeAuditSink,
type RuntimeApprovalVerifier,
} from '../runtime-provider-registry.service.js';
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
const CONTEXT = {
actorScope: OWNER_SCOPE,
channelId: 'cli',
correlationId: 'correlation-1',
};
class RecordingRuntimeProvider implements AgentRuntimeProvider {
readonly id = 'fleet';
readonly receivedScopes: RuntimeScope[] = [];
readonly sentMessages: RuntimeMessage[] = [];
terminateCalls = 0;
throwAfterSend = false;
throwAuthorization = false;
constructor(private readonly supported: RuntimeCapability[]) {}
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
this.receivedScopes.push(scope);
return { supported: this.supported };
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
this.receivedScopes.push(scope);
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
this.receivedScopes.push(scope);
return [];
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
this.receivedScopes.push(scope);
return [];
}
async *streamSession(
_sessionId: string,
_cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
this.receivedScopes.push(scope);
return;
}
async sendMessage(
_sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
this.receivedScopes.push(scope);
this.sentMessages.push(message);
if (this.throwAuthorization) {
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
}
if (this.throwAfterSend) {
throw new Error('provider acknowledgement failed');
}
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
this.receivedScopes.push(scope);
return {
attachmentId: 'attachment-1',
sessionId,
mode,
expiresAt: '2026-07-12T00:00:00.000Z',
};
}
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
}
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
this.terminateCalls += 1;
}
}
class RecordingAuditSink implements RuntimeAuditSink {
readonly events: RuntimeAuditEvent[] = [];
async record(event: RuntimeAuditEvent): Promise<void> {
this.events.push(event);
}
}
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
async consume(): Promise<boolean> {
return false;
}
}
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
async consume(
_approvalRef: string,
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
): Promise<boolean> {
this.consumedAction = action;
return true;
}
}
function makeService(
provider: RecordingRuntimeProvider,
audit: RuntimeAuditSink = new RecordingAuditSink(),
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
): RuntimeProviderService {
const registry = new AgentRuntimeProviderRegistry();
registry.register(provider);
return new RuntimeProviderService(registry, audit, approval);
}
describe('RuntimeProviderService security boundary', (): void => {
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
);
const providerScope = provider.receivedScopes[0];
expect(providerScope).toEqual({
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
});
expect(Object.isFrozen(providerScope)).toBe(true);
expect(audit.events).toContainEqual(
expect.objectContaining({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
resourceId: 'session-1',
durationMs: expect.any(Number),
}),
);
expect(JSON.stringify(audit.events)).not.toContain('hello');
expect(JSON.stringify(audit.events)).not.toContain('key-1');
});
it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let persisted: unknown;
const durableAudit = new RuntimeProviderAuditService({
logs: {
ingest: async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
},
},
} as never);
const service = makeService(provider, durableAudit);
await service.sendMessage(
'fleet',
'session/credential-canary=secret-value',
{ content: 'safe message', idempotencyKey: 'key-1' },
CONTEXT,
);
expect(provider.sentMessages).toHaveLength(1);
expect(JSON.stringify(persisted)).not.toContain('secret-value');
});
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider([]);
const service = makeService(provider);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/capability denied/);
expect(provider.sentMessages).toEqual([]);
});
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new DenyingApprovalVerifier();
const audit = new RecordingAuditSink();
const service = makeService(provider, audit, approval);
await expect(
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
).rejects.toThrow(/approval denied/);
expect(provider.terminateCalls).toBe(0);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new AcceptingApprovalVerifier();
const service = makeService(provider, new RecordingAuditSink(), approval);
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
expect(approval.consumedAction).toEqual({
providerId: 'fleet',
sessionId: 'session-1',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
agentName: process.env['MOSAIC_AGENT_NAME'],
});
expect(provider.terminateCalls).toBe(1);
});
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const unavailableAudit: RuntimeAuditSink = {
async record(): Promise<void> {
throw new Error('audit unavailable');
},
};
const service = makeService(provider, unavailableAudit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/audit unavailable/);
expect(provider.sentMessages).toEqual([]);
});
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAfterSend = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider acknowledgement failed/);
expect(provider.sentMessages).toHaveLength(1);
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
'requested',
'failed',
]);
expect(audit.events.at(-1)).toMatchObject({
errorCode: 'provider_error',
durationMs: expect.any(Number),
});
});
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAuthorization = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider authorization denied/);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
let persisted: unknown;
const ingest = async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
};
const service = new RuntimeProviderAuditService({ logs: { ingest } } as never);
await service.record({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
resourceId: 'session-1',
durationMs: 12,
});
expect(persisted).toMatchObject({
content: 'runtime.provider.audit',
metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }),
});
expect(JSON.stringify(persisted)).not.toContain('approval');
});
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let auditCalls = 0;
const audit: RuntimeAuditSink = {
async record(): Promise<void> {
auditCalls += 1;
if (auditCalls === 2) {
throw new Error('completion audit unavailable');
}
},
};
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).resolves.toBeUndefined();
expect(provider.sentMessages).toHaveLength(1);
expect(auditCalls).toBe(2);
});
});
+2 -62
View File
@@ -1,5 +1,4 @@
import { Global, Module } from '@nestjs/common';
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
@@ -9,79 +8,24 @@ import { SkillLoaderService } from './skill-loader.service.js';
import { ProvidersController } from './providers.controller.js';
import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js';
import { InteractionController } from './interaction.controller.js';
import { RoutingController } from './routing/routing.controller.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
import {
CONNECTOR_LEASE_POLICY,
ConnectorLeaseService,
DenyConnectorLeasePolicy,
} from './connector-lease.service.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
const registry = new AgentRuntimeProviderRegistry();
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
return registry;
}
@Global()
@Module({
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
providers: [
ProviderService,
ProviderCredentialsService,
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionRepository,
DurableSessionService,
ConnectorLeaseRepository,
DenyConnectorLeasePolicy,
{
provide: CONNECTOR_LEASE_POLICY,
useExisting: DenyConnectorLeasePolicy,
},
ConnectorLeaseService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
},
RuntimeProviderAuditService,
{
provide: RUNTIME_PROVIDER_AUDIT_SINK,
useExisting: RuntimeProviderAuditService,
},
{
provide: RUNTIME_APPROVAL_VERIFIER,
useExisting: CommandRuntimeApprovalVerifier,
},
RuntimeProviderService,
AgentService,
],
controllers: [
ProvidersController,
SessionsController,
AgentConfigsController,
InteractionController,
RoutingController,
],
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
exports: [
AgentService,
ProviderService,
@@ -89,10 +33,6 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionService,
RuntimeProviderService,
ConnectorLeaseService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],
})
export class AgentModule {}
+7 -58
View File
@@ -15,11 +15,9 @@ import {
type ToolDefinition,
} from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaicstack/brain';
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import type { Memory } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js';
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.service.js';
import { ProviderService } from './provider.service.js';
@@ -44,8 +42,6 @@ export interface ConversationHistoryMessage {
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: Date;
/** Validated, URI-referenced channel attachments preserved on session resume. */
attachments?: readonly ChannelAttachmentDto[];
}
export interface AgentSessionOptions {
@@ -139,9 +135,6 @@ export class AgentService implements OnModuleDestroy {
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@Inject(SessionGCService) private readonly gc: SessionGCService,
@Optional()
@Inject(OPERATOR_MEMORY_PLUGIN)
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
) {}
/**
@@ -153,7 +146,6 @@ export class AgentService implements OnModuleDestroy {
private buildToolsForSandbox(
sandboxDir: string,
sessionUserId: string | undefined,
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
): ToolDefinition[] {
return [
...createBrainTools(this.brain),
@@ -162,9 +154,6 @@ export class AgentService implements OnModuleDestroy {
this.memory,
this.embeddingService.available ? this.embeddingService : null,
sessionUserId,
this.operatorMemory && sessionScope
? { plugin: this.operatorMemory, scope: sessionScope }
: undefined,
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
@@ -239,7 +228,6 @@ export class AgentService implements OnModuleDestroy {
isAdmin: options.isAdmin,
agentConfigId: options.agentConfigId,
userId: options.userId,
tenantId: options.tenantId,
conversationHistory: options.conversationHistory,
};
this.logger.log(
@@ -279,15 +267,7 @@ export class AgentService implements OnModuleDestroy {
}
// Build per-session tools scoped to the sandbox directory and authenticated user
const sessionUserId = mergedOptions?.userId;
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
const sandboxTools = this.buildToolsForSandbox(
sandboxDir,
sessionUserId,
sessionUserId && sessionTenantId
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
: undefined,
);
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
// Combine static tools with dynamically discovered MCP client tools and skill tools
const mcpTools = this.mcpClientService.getToolDefinitions();
@@ -382,7 +362,7 @@ export class AgentService implements OnModuleDestroy {
sandboxDir,
allowedTools,
userId: mergedOptions?.userId,
tenantId: sessionTenantId,
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName,
metrics: {
@@ -431,7 +411,7 @@ export class AgentService implements OnModuleDestroy {
const formatMessage = (msg: ConversationHistoryMessage): string => {
const roleLabel =
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`;
return `**${roleLabel}:** ${msg.content}`;
};
const formatted = history.map((msg) => formatMessage(msg));
@@ -490,21 +470,6 @@ export class AgentService implements OnModuleDestroy {
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) {
if (!options?.provider && !options?.modelId) {
return this.providerService.getDefaultModel() ?? null;
@@ -691,19 +656,7 @@ export class AgentService implements OnModuleDestroy {
session.channels.delete(channel);
}
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> {
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
@@ -711,16 +664,12 @@ export class AgentService implements OnModuleDestroy {
this.assertSessionScope(session, scope);
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)
let effectiveMessage = `${message}${attachmentContext}`;
let effectiveMessage = message;
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
@@ -1,341 +0,0 @@
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();
});
});
@@ -1,76 +0,0 @@
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' });
});
});
@@ -1,149 +0,0 @@
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);
});
});
@@ -1,354 +0,0 @@
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),
});
}
@@ -1,285 +0,0 @@
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,10 +0,0 @@
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
/** Server-side request for a replay-safe provider message. */
export interface ProviderOutboxDto {
sessionId: string;
idempotencyKey: string;
correlationId: string;
content: string;
context: RuntimeProviderRequestContext;
}
@@ -1,416 +0,0 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',
sessionId: 'tess-pglite-session',
tenantId: 'tenant-pglite',
ownerId: 'tess-owner',
providerId: 'fleet',
runtimeSessionId: 'nova',
};
describe('DurableSessionRepository', () => {
let dataDir: string | undefined;
let handle: DbHandle;
let previousAuthSecret: string | undefined;
beforeAll(async (): Promise<void> => {
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
await seedOwner(handle);
}, 30_000);
beforeEach(async (): Promise<void> => {
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
});
afterAll(async (): Promise<void> => {
await handle.close();
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
});
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await beforeRestart.create(IDENTITY);
await beforeRestart.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-before-kill',
correlationId: 'correlation-before-kill',
content: 'resume after a kill',
});
await beforeRestart.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-before-kill',
correlationId: 'correlation-before-kill',
channelId: 'cli',
kind: 'provider.send',
content: 'one response only',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-before-kill',
cursor: 'cursor-before-kill',
summary: 'restart-safe state',
compactionEpoch: 1,
});
await beforeRestart.handoff({
sessionId: IDENTITY.sessionId,
handoffId: 'handoff-before-kill',
destination: 'mos',
correlationId: 'correlation-before-kill',
checkpointId: 'checkpoint-before-kill',
status: 'pending',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-after-handoff',
cursor: 'cursor-after-handoff',
summary: 'newer state cannot strand the portable handoff',
compactionEpoch: 2,
});
await handle.close();
handle = createPgliteDb(dataDir!);
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const recovered = await afterRestart.recover(IDENTITY.sessionId);
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
const handled: string[] = [];
const effects: string[] = [];
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
expect(recovered.identity).toEqual(IDENTITY);
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
expect(handled).toEqual(['inbox-before-kill']);
expect(effects).toEqual(['outbox-before-kill']);
}, 30_000);
it('redacts sensitive durable payloads before persistence', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-inbox',
correlationId: 'correlation-redaction',
content: 'api_key=super-secret-canary',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-outbox',
correlationId: 'correlation-redaction',
channelId: 'cli',
kind: 'provider.send',
content: 'email [email protected] api_key=super-secret-canary',
});
await coordinator.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'redacted-checkpoint',
cursor: 'bearer super-secret-canary',
summary: 'email [email protected]',
compactionEpoch: 0,
});
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
const [persisted] = await handle.db
.select({ content: interactionInbox.content })
.from(interactionInbox)
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
expect(JSON.stringify(snapshot)).not.toContain('[email protected]');
expect(persisted?.content).not.toContain('super-secret-canary');
expect(persisted?.content).not.toContain('[REDACTED]');
}, 30_000);
it('fails closed when the configured idempotency secret is unavailable', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const secret = process.env['BETTER_AUTH_SECRET'];
delete process.env['BETTER_AUTH_SECRET'];
try {
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'requires-idempotency-secret',
correlationId: 'correlation-secret',
content: 'sensitive payload',
}),
).rejects.toThrow(/required for durable idempotency digests/);
} finally {
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = secret;
}
}, 30_000);
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const input = {
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-secret-conflict',
cursor: 'api_key=secret-one',
summary: 'bearer secret-one',
compactionEpoch: 1,
};
await coordinator.checkpoint(input);
await expect(
coordinator.checkpoint({
...input,
cursor: 'api_key=secret-two',
summary: 'bearer secret-two',
}),
).rejects.toThrow(/checkpoint identity conflict/);
const [persisted] = await handle.db
.select({
digest: interactionCheckpoints.contentDigest,
cursor: interactionCheckpoints.cursor,
})
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
expect(persisted?.cursor).not.toContain('secret-one');
expect(persisted?.digest).not.toBe(
createHash('sha256')
.update(JSON.stringify([input.cursor, input.summary]))
.digest('hex'),
);
await coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a\u0000b',
summary: 'c',
});
await expect(
coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a',
summary: 'b\u0000c',
}),
).rejects.toThrow(/checkpoint identity conflict/);
}, 30_000);
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const inbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-secret-conflict',
correlationId: 'correlation-inbox-secret',
content: 'api_key=secret-one',
};
const outbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-secret-conflict',
correlationId: 'correlation-outbox-secret',
channelId: 'cli',
kind: 'provider.send',
content: 'api_key=secret-one',
};
await coordinator.receive(inbox);
await coordinator.enqueueOutbox(outbox);
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
/idempotency conflict/,
);
await expect(
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'correlation-inbox',
content: 'original inbox',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'cli',
kind: 'provider.send',
content: 'original outbox',
});
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'forged-correlation',
content: 'original inbox',
}),
).rejects.toThrow(/idempotency conflict/);
await expect(
coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'forged-channel',
kind: 'provider.send',
content: 'original outbox',
}),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'live-effect',
correlationId: 'correlation-live',
content: 'must not duplicate',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-live',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
status: 'processing',
});
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
await expect(
service.recoverProviderSession(IDENTITY.sessionId, {
...input,
context: {
...input.context,
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
},
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
});
}, 30_000);
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'mismatch-effect',
correlationId: 'correlation-expected',
content: 'must remain pending',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-expected',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
await expect(
service.dispatchProviderOutbox(IDENTITY.sessionId, {
...input,
correlationId: 'correlation-forged',
context: { ...input.context, correlationId: 'correlation-forged' },
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
});
}, 30_000);
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const first = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'scoped-effect-one',
correlationId: 'correlation-one',
content: 'first result',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-one',
},
};
const second = {
...first,
idempotencyKey: 'scoped-effect-two',
correlationId: 'correlation-two',
content: 'second result',
context: { ...first.context, correlationId: 'correlation-two' },
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(first);
await service.queueProviderSend(second);
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
IDENTITY.providerId,
IDENTITY.runtimeSessionId,
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
first.context,
);
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
],
});
}, 30_000);
});
async function seedOwner(handle: DbHandle): Promise<void> {
await handle.db.execute(sql`
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
VALUES ('tess-owner', 'Tess Owner', '[email protected]', false, now(), now())
`);
}
@@ -1,529 +0,0 @@
import { createHash, createHmac } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
desc,
eq,
interactionCheckpoints,
interactionHandoffs,
interactionInbox,
interactionOutbox,
interactionSessions,
type Db,
} from '@mosaicstack/db';
import { seal, unseal } from '@mosaicstack/auth';
import { redactSensitiveContent } from '@mosaicstack/log';
import type {
DurableCheckpoint,
DurableCheckpointInput,
DurableEnqueueResult,
DurableHandoff,
DurableHandoffInput,
DurableInboxEntry,
DurableInboxInput,
DurableInboxStatus,
DurableOutboxEntry,
DurableOutboxInput,
DurableOutboxStatus,
DurableSessionIdentity,
DurableSessionSnapshot,
DurableSessionStore,
} from '@mosaicstack/agent';
import { DB } from '../database/database.module.js';
@Injectable()
export class DurableSessionRepository implements DurableSessionStore {
constructor(@Inject(DB) private readonly db: Db) {}
async create(identity: DurableSessionIdentity): Promise<void> {
await this.db
.insert(interactionSessions)
.values({
id: identity.sessionId,
agentName: identity.agentName,
tenantId: identity.tenantId,
ownerId: identity.ownerId,
providerId: identity.providerId,
runtimeSessionId: identity.runtimeSessionId,
})
.onConflictDoNothing();
const existing = await this.session(identity.sessionId);
if (!existing || !sameEnrollmentScope(existing, identity)) {
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
}
// A recovered/re-enrolled runtime can receive a new provider session ID;
// the conversation handle and owner scope remain immutable.
if (
existing.providerId !== identity.providerId ||
existing.runtimeSessionId !== identity.runtimeSessionId
) {
await this.db
.update(interactionSessions)
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
.where(eq(interactionSessions.id, identity.sessionId));
}
}
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
const identity = await this.session(sessionId);
if (!identity) return null;
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
this.db
.select()
.from(interactionInbox)
.where(eq(interactionInbox.sessionId, sessionId))
.orderBy(asc(interactionInbox.createdAt)),
this.db
.select()
.from(interactionOutbox)
.where(eq(interactionOutbox.sessionId, sessionId))
.orderBy(asc(interactionOutbox.createdAt)),
this.db
.select()
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.sessionId, sessionId))
.orderBy(
desc(interactionCheckpoints.compactionEpoch),
desc(interactionCheckpoints.createdAt),
)
.limit(1),
this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.sessionId, sessionId))
.orderBy(asc(interactionHandoffs.createdAt)),
]);
const checkpoint = checkpoints[0];
return {
identity,
inbox: inbox.map(toInbox),
outbox: outbox.map(toOutbox),
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
handoffs: handoffs.map(toHandoff),
};
}
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableInboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionInbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionInbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionInbox)
.where(
and(
eq(interactionInbox.sessionId, input.sessionId),
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
const entry = toInbox(existing[0]);
if (
!sameInbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionInbox)
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
)
.orderBy(asc(interactionInbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionInbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
.returning();
if (claimed[0]) return toInbox(claimed[0]);
}
return null;
}
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'processed', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async enqueueOutbox(
input: DurableOutboxInput,
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableOutboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionOutbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionOutbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionOutbox)
.where(
and(
eq(interactionOutbox.sessionId, input.sessionId),
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
const entry = toOutbox(existing[0]);
if (
!sameOutbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionOutbox)
.where(
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
)
.orderBy(asc(interactionOutbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
.returning();
if (claimed[0]) return toOutbox(claimed[0]);
}
return null;
}
async claimOutboxByKey(
sessionId: string,
idempotencyKey: string,
): Promise<DurableOutboxEntry | null> {
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'pending'),
),
)
.returning();
return claimed[0] ? toOutbox(claimed[0]) : null;
}
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'delivered', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async checkpoint(input: DurableCheckpointInput): Promise<void> {
// Compute identity before redaction. The persisted digest is keyed so a database
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
const checkpoint: DurableCheckpointInput = {
...input,
cursor: redactSensitiveContent(input.cursor).content,
summary: redactSensitiveContent(input.summary).content,
};
const inserted = await this.db
.insert(interactionCheckpoints)
.values({
...checkpoint,
contentDigest: digest,
cursor: seal(checkpoint.cursor),
summary: seal(checkpoint.summary),
})
.onConflictDoNothing()
.returning({ checkpointId: interactionCheckpoints.checkpointId });
if (inserted[0]) return;
const existing = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, input.sessionId),
eq(interactionCheckpoints.checkpointId, input.checkpointId),
),
)
.limit(1);
if (
!existing[0] ||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
) {
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
}
}
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
const checkpoints = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, sessionId),
eq(interactionCheckpoints.checkpointId, checkpointId),
),
)
.limit(1);
const checkpoint = checkpoints[0];
return checkpoint ? toCheckpoint(checkpoint) : null;
}
async handoff(input: DurableHandoffInput): Promise<void> {
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
if (!checkpoint) {
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const inserted = await this.db
.insert(interactionHandoffs)
.values({ ...input })
.onConflictDoNothing()
.returning({ handoffId: interactionHandoffs.handoffId });
if (inserted[0]) return;
const existing = await this.findHandoff(input.handoffId);
if (!existing || !sameHandoff(existing, input)) {
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
}
}
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
const handoffs = await this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.handoffId, handoffId))
.limit(1);
const handoff = handoffs[0];
return handoff ? toHandoff(handoff) : null;
}
async requeueInFlight(sessionId: string): Promise<void> {
// Inbox handlers are process-local work. A provider outbox claim may have
// reached an external target before a crash, so it is deliberately not
// replayed by generic recovery.
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
);
}
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
const sessions = await this.db
.select()
.from(interactionSessions)
.where(eq(interactionSessions.id, sessionId))
.limit(1);
const session = sessions[0];
return session
? {
agentName: session.agentName,
sessionId: session.id,
tenantId: session.tenantId,
ownerId: session.ownerId,
providerId: session.providerId,
runtimeSessionId: session.runtimeSessionId,
}
: null;
}
}
function contentDigest(content: string): string {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
}
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
}
function matchesContentDigest(stored: string, content: string): boolean {
return (
stored === contentDigest(content) ||
stored === createHash('sha256').update(content).digest('hex')
);
}
function matchesCheckpointDigest(stored: string, digest: string): boolean {
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
return stored === digest;
}
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
return (
left.agentName === right.agentName &&
left.sessionId === right.sessionId &&
left.tenantId === right.tenantId &&
left.ownerId === right.ownerId
);
}
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.content === right.content
);
}
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.channelId === right.channelId &&
left.kind === right.kind &&
left.content === right.content
);
}
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
return (
left.sessionId === right.sessionId &&
left.checkpointId === right.checkpointId &&
left.compactionEpoch === right.compactionEpoch
);
}
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
return (
left.sessionId === right.sessionId &&
left.handoffId === right.handoffId &&
left.destination === right.destination &&
left.correlationId === right.correlationId &&
left.checkpointId === right.checkpointId &&
left.status === right.status
);
}
function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
content: unseal(row.content),
status: row.status,
};
}
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
channelId: row.channelId,
kind: row.kind,
content: unseal(row.content),
status: row.status,
};
}
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
return {
sessionId: row.sessionId,
checkpointId: row.checkpointId,
cursor: unseal(row.cursor),
summary: unseal(row.summary),
compactionEpoch: row.compactionEpoch,
};
}
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
return {
sessionId: row.sessionId,
handoffId: row.handoffId,
destination: row.destination,
correlationId: row.correlationId,
checkpointId: row.checkpointId,
status: row.status,
};
}
@@ -1,123 +0,0 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import type { ProviderOutboxDto } from './durable-session.dto.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
* uses composition: raw state methods cannot be injected into channel, CLI, or
* MCP adapters without a server-derived actor/tenant/correlation context.
*/
@Injectable()
export class DurableSessionService {
private readonly coordinator: DurableSessionCoordinator;
constructor(
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
) {
this.coordinator = new DurableSessionCoordinator(repository);
}
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
async enroll(
identity: DurableSessionIdentity,
context: RuntimeProviderRequestContext,
): Promise<void> {
if (
identity.ownerId !== context.actorScope.userId ||
identity.tenantId !== context.actorScope.tenantId
) {
throw new ForbiddenException('Durable session enrollment scope mismatch');
}
await this.coordinator.create(identity);
}
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(input.sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.enqueueOutbox({
sessionId: input.sessionId,
idempotencyKey: input.idempotencyKey,
correlationId: input.correlationId,
channelId: input.context.channelId,
kind: 'provider.send',
content: input.content,
});
}
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
if (sessionId !== input.sessionId) {
throw new ForbiddenException('Durable outbox session mismatch');
}
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
const pendingEntry = snapshot.outbox.find(
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
);
if (!pendingEntry) return;
// Validate immutable routing before claiming. A caller with a mismatched
// correlation/channel must not strand a pending external side effect.
this.assertOutboxScope(pendingEntry, input);
await this.coordinator.dispatchOutboxEntry(
sessionId,
input.idempotencyKey,
async (entry): Promise<void> => {
this.assertOutboxScope(entry, input);
await this.runtimeProviders.sendMessage(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
input.context,
);
},
);
}
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
sessionId,
content: '',
idempotencyKey: 'read-only',
correlationId: context.correlationId,
context,
});
return snapshot;
}
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.recover(sessionId);
}
private assertOutboxScope(
entry: { kind: string; correlationId: string; channelId: string },
input: ProviderOutboxDto,
): void {
if (
entry.kind !== 'provider.send' ||
entry.correlationId !== input.correlationId ||
entry.channelId !== input.context.channelId
) {
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
}
}
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
if (
input.context.actorScope.userId !== ownerId ||
input.context.actorScope.tenantId !== tenantId ||
input.context.correlationId !== input.correlationId
) {
throw new ForbiddenException('Durable session scope or correlation mismatch');
}
}
}
@@ -1,176 +0,0 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Global, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentModule } from './agent.module.js';
import { AUTH } from '../auth/auth.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { DB } from '../database/database.module.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
} from './runtime-provider-registry.service.js';
import { DurableSessionService } from './durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
import { RoutingService } from './routing.service.js';
import { RoutingEngineService } from './routing/routing-engine.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' };
@Module({})
class EmptyAgentDependencyModule {}
@Global()
@Module({
providers: [
{
provide: AUTH,
useValue: {
api: {
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
headers.get('cookie') === 'session=trusted'
? { user: authenticatedUser, session: { id: 'session-1' } }
: null,
),
},
},
},
AuthGuard,
{ provide: BRAIN, useValue: {} },
{ provide: DB, useValue: {} },
],
exports: [AUTH, AuthGuard, BRAIN, DB],
})
class AuthenticatedRequestModule {}
/**
* This is deliberately an HTTP test rather than a controller unit test: it
* exercises AgentModule's actual provider factory, Nest DI, and AuthGuard.
*/
describe('Hermes runtime provider reachability', (): void => {
let app: NestFastifyApplication | undefined;
beforeAll(async (): Promise<void> => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const moduleRef = await Test.createTestingModule({
imports: [AuthenticatedRequestModule, AgentModule],
})
.overrideModule(CoordModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(McpClientModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(SkillsModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(GCModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(LogModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(CommandsModule)
.useModule(EmptyAgentDependencyModule)
.overrideProvider(RuntimeProviderAuditService)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
.useValue({ consume: vi.fn().mockResolvedValue(false) })
.overrideProvider(DurableSessionService)
.useValue({})
.overrideProvider(DurableSessionRepository)
.useValue({})
.overrideProvider(AgentService)
.useValue({})
.overrideProvider(ProviderService)
.useValue({})
.overrideProvider(ProviderCredentialsService)
.useValue({})
.overrideProvider(RoutingService)
.useValue({})
.overrideProvider(RoutingEngineService)
.useValue({})
.overrideProvider(SkillLoaderService)
.useValue({})
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async (): Promise<void> => {
await app?.close();
});
it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise<void> => {
if (!app) throw new Error('Nest application did not initialize');
const attachDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/attach',
headers: { 'x-correlation-id': 'correlation-1' },
payload: { mode: 'read' },
});
expect(attachDenied.statusCode).toBe(401);
const sendDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/send',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
payload: {},
});
expect(sendDenied.statusCode).toBe(403);
expect(sendDenied.json()).toMatchObject({
message: 'Content and idempotency key are required',
});
const stopDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/stop',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
payload: {},
});
expect(stopDenied.statusCode).toBe(403);
expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' });
});
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
if (!app) throw new Error('Nest application did not initialize');
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider);
const denied = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { 'x-correlation-id': 'correlation-1' },
});
expect(denied.statusCode).toBe(401);
const response = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
});
@@ -1,46 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
const scope = {
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
};
describe('GatewayHermesRuntimeTransport', () => {
it('preserves a configured path prefix and authenticates the concrete runtime request', async () => {
const fetchFn = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 }));
const transport = new GatewayHermesRuntimeTransport(
'https://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']);
expect(fetchFn).toHaveBeenCalledWith(
new URL('https://runtime.example.test/hermes/capabilities'),
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer test-service-token',
'x-mosaic-channel-id': 'cli',
}),
}),
);
});
it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => {
const fetchFn = vi.fn();
const transport = new GatewayHermesRuntimeTransport(
'http://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS');
expect(fetchFn).not.toHaveBeenCalled();
});
});
@@ -1,121 +0,0 @@
import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent';
import type {
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeMessage,
RuntimeScope,
RuntimeStreamEvent,
} from '@mosaicstack/types';
/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */
export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport {
constructor(
private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(),
private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(),
private readonly fetchFn: typeof fetch = fetch,
) {}
async capabilities(scope: RuntimeScope): Promise<string[]> {
return this.request<string[]>('/capabilities', scope);
}
async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> {
return this.request<{ status: string; detail?: string }>('/health', scope);
}
async sessions(scope: RuntimeScope): Promise<HermesLegacySession[]> {
return this.request<HermesLegacySession[]>('/sessions', scope);
}
async *stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
const params = new URLSearchParams(cursor ? { cursor } : {});
const events = await this.request<RuntimeStreamEvent[]>(
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
scope,
);
yield* events;
}
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
method: 'POST',
body: message,
});
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
return this.request<RuntimeAttachHandle>(
`/sessions/${encodeURIComponent(sessionId)}/attach`,
scope,
{
method: 'POST',
body: { mode },
},
);
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
method: 'DELETE',
});
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
method: 'POST',
body: { approvalRef },
});
}
private async request<T>(
path: string,
scope: RuntimeScope,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
if (!this.baseUrl || !this.serviceToken) {
throw new Error(
'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport',
);
}
const endpoint = new URL(this.baseUrl);
if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) {
throw new Error('Hermes runtime transport requires HTTPS outside loopback');
}
const response = await this.fetchFn(
new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`),
{
method: init.method ?? 'GET',
headers: {
accept: 'application/json',
authorization: `Bearer ${this.serviceToken}`,
'x-mosaic-actor-id': scope.actorId,
'x-mosaic-tenant-id': scope.tenantId,
'x-mosaic-channel-id': scope.channelId,
'x-correlation-id': scope.correlationId,
...(init.body ? { 'content-type': 'application/json' } : {}),
},
...(init.body ? { body: JSON.stringify(init.body) } : {}),
},
);
if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`);
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
}
function isLoopbackHttp(endpoint: URL): boolean {
return (
endpoint.protocol === 'http:' &&
(endpoint.hostname === 'localhost' ||
endpoint.hostname === '127.0.0.1' ||
endpoint.hostname === '::1')
);
}
@@ -1,263 +0,0 @@
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
import { firstValueFrom } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
RuntimeApprovalDeniedError,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import { InteractionController } from './interaction.controller.js';
describe('InteractionController', (): void => {
afterEach(() => vi.restoreAllMocks());
it('maps a denied runtime approval to Fastify HTTP 403', () => {
const send = vi.fn();
const status = vi.fn().mockReturnValue({ send });
const response = { status };
const host = { switchToHttp: () => ({ getResponse: () => response }) };
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
expect(status).toHaveBeenCalledWith(403);
expect(send).toHaveBeenCalledWith({
statusCode: 403,
message: 'Runtime termination approval denied',
});
});
it('honors a differently named configured instance without a code change', async () => {
const prior = process.env['MOSAIC_AGENT_NAME'];
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
const controller = new InteractionController(runtime as never, {} as never);
await expect(
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).resolves.toEqual([]);
await expect(
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).rejects.toThrow('Interaction agent is not configured');
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prior;
});
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const registry = createGatewayRuntimeProviderRegistry();
const runtime = new RuntimeProviderService(
registry,
{ record: vi.fn().mockResolvedValue(undefined) },
{ consume: vi.fn().mockResolvedValue(false) },
);
const controller = new InteractionController(runtime, {} as never);
await expect(
controller.transitionalCapabilities(
'Nova',
'runtime.hermes',
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
it('rejects a request without the non-simple correlation header', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
'X-Correlation-Id is required',
);
});
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.enroll(
'Nova',
'conversation-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
expect(durable.enroll).toHaveBeenCalledWith(
{
agentName: 'Nova',
sessionId: 'conversation-1',
tenantId: 'team',
ownerId: 'owner',
providerId: 'fleet',
runtimeSessionId: 'runtime-1',
},
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('rejects an invalid attach mode before invoking a provider', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
await expect(
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
).rejects.toThrow('Interaction attach mode is invalid');
});
it('resumes a durable session by attaching and streaming its runtime events', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtimeEvent = {
type: 'message.delta' as const,
sessionId: 'runtime-1',
cursor: 'cursor-1',
occurredAt: '2026-07-13T00:00:00.000Z',
content: 'resumed',
};
const runtime = {
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
streamSession: vi.fn(async function* () {
yield runtimeEvent;
}),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
await expect(
firstValueFrom(
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
),
).resolves.toEqual({ data: runtimeEvent });
expect(runtime.attach).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'read',
expect.objectContaining({ correlationId: 'corr-1' }),
);
expect(runtime.streamSession).toHaveBeenCalledWith(
'fleet',
'runtime-1',
undefined,
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
resolveSnapshot = resolve;
});
const runtime = { streamSession: vi.fn() };
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
const controller = new InteractionController(runtime as never, durable as never);
const subscription = controller
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
.subscribe();
subscription.unsubscribe();
resolveSnapshot({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(runtime.streamSession).not.toHaveBeenCalled();
});
it.each([
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
[
'session-agent mismatch',
{
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
},
],
])('denies a CLI stop for %s', async (_reason, durable) => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toBeDefined();
expect(runtime.terminate).not.toHaveBeenCalled();
});
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toThrow('Runtime termination approval denied');
});
it('uses the durable session identity and runtime registry for an approved stop', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
);
expect(runtime.terminate).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'approval-1',
expect.objectContaining({
correlationId: 'corr-1',
actorScope: { userId: 'owner', tenantId: 'owner' },
}),
);
});
});
@@ -1,293 +0,0 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Sse,
Inject,
Param,
Post,
Query,
UseGuards,
UseFilters,
} from '@nestjs/common';
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
import { Observable } from 'rxjs';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { DurableSessionService } from './durable-session.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Authenticated HTTP boundary for operator interaction clients. Identity is
* selected from deployment configuration, never a client-side command name.
*/
@Controller('api/interaction/:agentName')
@UseGuards(AuthGuard)
@UseFilters(RuntimeApprovalDeniedFilter)
export class InteractionController {
constructor(
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
) {}
@Get('sessions')
async sessions(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.listSessions(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('transitional-capabilities')
async transitionalCapabilities(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.transitionalCapabilityMatrix(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('tree')
async tree(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.getSessionTree(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
/**
* Bind an existing, authorized runtime session to the stable conversation ID.
* This is the lifecycle boundary where both runtime identifiers are known.
*/
@Post('sessions/:sessionId/enroll')
async enroll(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const providerId = this.requiredProvider(body.providerId ?? '');
const runtimeSessionId = body.runtimeSessionId?.trim();
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
const context = this.context(user, correlationId);
const sessions = await this.runtime.listSessions(providerId, context);
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
throw new ForbiddenException('Runtime session is not visible to this actor');
}
await this.durable.enroll(
{
agentName,
sessionId,
tenantId: context.actorScope.tenantId,
ownerId: context.actorScope.userId,
providerId,
runtimeSessionId,
},
context,
);
return { status: 'enrolled', sessionId };
}
@Post('sessions/:sessionId/attach')
async attach(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { mode?: RuntimeAttachMode } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const mode = body.mode ?? 'read';
if (mode !== 'read' && mode !== 'control') {
throw new ForbiddenException('Interaction attach mode is invalid');
}
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
return this.runtime.attach(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
mode,
context,
);
}
@Sse('sessions/:sessionId/stream')
stream(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Query('cursor') cursor: string | undefined,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Observable<{ data: RuntimeStreamEvent }> {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
return new Observable((subscriber) => {
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
let cancelled = false;
void (async (): Promise<void> => {
try {
const snapshot = await this.durable.getSnapshot(sessionId, context);
if (cancelled || subscriber.closed) return;
this.assertSessionAgent(snapshot.identity.agentName, agentName);
iterator = this.runtime
.streamSession(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
cursor?.trim() || undefined,
context,
)
[Symbol.asyncIterator]();
if (cancelled || subscriber.closed) {
await iterator.return?.();
return;
}
while (!cancelled && !subscriber.closed) {
const next = await iterator.next();
if (next.done || cancelled || subscriber.closed) break;
subscriber.next({ data: next.value });
}
if (!subscriber.closed) subscriber.complete();
} catch (error: unknown) {
if (!subscriber.closed) subscriber.error(error);
}
})();
return (): void => {
cancelled = true;
void iterator?.return?.();
};
});
}
@Post('sessions/:sessionId/send')
async send(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { content?: string; idempotencyKey?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
throw new ForbiddenException('Content and idempotency key are required');
}
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
const input = {
sessionId,
content: body.content,
idempotencyKey: body.idempotencyKey,
correlationId: context.correlationId,
context,
};
await this.durable.queueProviderSend(input);
await this.durable.dispatchProviderOutbox(sessionId, input);
return { status: 'queued', sessionId };
}
@Post('sessions/:sessionId/stop')
async stop(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { approvalRef?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.approvalRef?.trim())
throw new ForbiddenException('Exact-action approval is required');
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.runtime.terminate(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
body.approvalRef,
context,
);
return { status: 'stopped', sessionId };
}
@Post('sessions/:sessionId/recover')
async recover(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.durable.recoverProviderSession(sessionId, {
sessionId,
content: '',
idempotencyKey: `recovery:${context.correlationId}`,
correlationId: context.correlationId,
context,
});
return { status: 'recovered', sessionId };
}
private context(
user: AuthenticatedUserLike,
correlationId?: string,
): RuntimeProviderRequestContext {
const requestCorrelationId = correlationId?.trim();
// This non-simple request header is mandatory for mutations. Browser
// cross-origin requests cannot set it without a CORS preflight, and the
// gateway's allowlist rejects untrusted origins before the handler runs.
if (!requestCorrelationId) {
throw new ForbiddenException('X-Correlation-Id is required');
}
return {
actorScope: scopeFromUser(user),
channelId: 'cli',
correlationId: requestCorrelationId,
};
}
private assertConfiguredAgent(agentName: string): void {
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!configured || configured !== agentName) {
throw new ForbiddenException('Interaction agent is not configured for this request');
}
}
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
if (sessionAgentName !== agentName)
throw new ForbiddenException('Interaction session identity mismatch');
}
private requiredProvider(providerId: string): string {
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
return providerId;
}
}
+2 -23
View File
@@ -107,7 +107,8 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
*/
private startHealthCheckScheduler(): void {
const intervalSecs = this.effectiveHealthCheckIntervalSecs();
const intervalSecs =
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS;
const intervalMs = intervalSecs * 1000;
// Run an initial check immediately (non-blocking)
@@ -175,28 +176,6 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
});
}
/**
* Returns the effective provider operational policy without credentials,
* endpoints, request content, or provider error details.
*/
getEffectivePolicyStatus(): {
healthCheckIntervalSecs: number;
configuredProviders: string[];
availableModelCount: number;
} {
return {
healthCheckIntervalSecs: this.effectiveHealthCheckIntervalSecs(),
configuredProviders: this.adapters.map((adapter) => adapter.name),
availableModelCount: this.registry?.getAvailable().length ?? 0,
};
}
private effectiveHealthCheckIntervalSecs(): number {
return (
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS
);
}
// ---------------------------------------------------------------------------
// Adapter-pattern API
// ---------------------------------------------------------------------------
@@ -1,46 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { ProvidersController } from './providers.controller.js';
describe('ProvidersController operational status', (): void => {
it('reports provider latency and effective policy without exposing provider error details', (): void => {
const providerService = {
getProvidersHealth: vi.fn(() => [
{
name: 'fleet',
status: 'down',
latencyMs: 42,
lastChecked: '2026-07-12T00:00:00.000Z',
modelCount: 0,
error: 'credential-canary=secret-value',
},
]),
getEffectivePolicyStatus: vi.fn(() => ({
healthCheckIntervalSecs: 60,
configuredProviders: ['fleet'],
availableModelCount: 0,
})),
};
const controller = new ProvidersController(providerService as never, {} as never, {} as never);
const status = controller.status();
expect(status).toEqual({
providers: [
{
name: 'fleet',
status: 'down',
latencyMs: 42,
lastChecked: '2026-07-12T00:00:00.000Z',
modelCount: 0,
errorCode: 'provider_unavailable',
},
],
effectivePolicy: {
healthCheckIntervalSecs: 60,
configuredProviders: ['fleet'],
availableModelCount: 0,
},
});
expect(JSON.stringify(status)).not.toContain('secret-value');
});
});
+1 -21
View File
@@ -33,20 +33,7 @@ export class ProvidersController {
@Get('health')
health() {
return { providers: this.safeProviderHealth() };
}
/**
* Safe operational status for troubleshooting and readiness checks. Provider
* errors are reduced to a stable code so credentials and remote responses
* cannot leak through this endpoint.
*/
@Get('status')
status() {
return {
providers: this.safeProviderHealth(),
effectivePolicy: this.providerService.getEffectivePolicyStatus(),
};
return { providers: this.providerService.getProvidersHealth() };
}
@Post('test')
@@ -64,13 +51,6 @@ export class ProvidersController {
return this.routingService.rank(criteria);
}
private safeProviderHealth() {
return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({
...provider,
...(error ? { errorCode: 'provider_unavailable' } : {}),
}));
}
// ── Credential CRUD ──────────────────────────────────────────────────────
/**
@@ -1,13 +0,0 @@
import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common';
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */
@Catch(RuntimeApprovalDeniedError)
export class RuntimeApprovalDeniedFilter implements ExceptionFilter {
catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<{
status(code: number): { send(body: { statusCode: number; message: string }): void };
}>();
response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' });
}
}
@@ -1,500 +0,0 @@
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import {
createRuntimeAuditLogEntry,
type LogService,
type RuntimeAuditErrorCode,
} from '@mosaicstack/log';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
TransitionalCapabilityInventoryEntry,
TransitionalCapabilityInventoryProvider,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { LOG_SERVICE } from '../log/log.tokens.js';
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
export type RuntimeProviderOperation =
| RuntimeCapability
| 'runtime.capabilities'
| 'runtime.health'
| 'runtime.transitional-capabilities';
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
export interface RuntimeProviderRequestContext {
actorScope: ActorTenantScope;
channelId: string;
correlationId: string;
}
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
export interface RuntimeAuditEvent {
providerId: string;
operation: RuntimeProviderOperation;
outcome: RuntimeProviderAuditOutcome;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
resourceId?: string;
durationMs?: number;
errorCode?: RuntimeAuditErrorCode;
}
export interface RuntimeAuditSink {
record(event: RuntimeAuditEvent): Promise<void>;
}
/** Exact action shape that a durable approval implementation must consume once. */
export interface RuntimeTerminationAction {
providerId: string;
sessionId: string;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
agentName: string;
}
export interface RuntimeApprovalVerifier {
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
}
function isTransitionalInventoryProvider(
provider: AgentRuntimeProvider,
): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider {
return (
typeof (provider as Partial<TransitionalCapabilityInventoryProvider>)
.transitionalCapabilityMatrix === 'function'
);
}
function configuredAgentName(): string {
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!agentName) throw new RuntimeApprovalDeniedError();
return agentName;
}
export class RuntimeApprovalDeniedError extends Error {
constructor() {
super('Runtime termination approval denied');
}
}
/**
* The default denies all runtime termination until a durable, exact-action
* approval implementation is configured. This is safer than a permissive stub.
*/
@Injectable()
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
return false;
}
}
/**
* Temporary metadata-only audit sink. M1 observability can replace this token
* with a durable audit writer without changing provider call sites.
*/
@Injectable()
export class RuntimeProviderAuditService implements RuntimeAuditSink {
private readonly logger = new Logger(RuntimeProviderAuditService.name);
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
async record(event: RuntimeAuditEvent): Promise<void> {
const entry = createRuntimeAuditLogEntry(event);
await this.logService.logs.ingest(entry);
this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata }));
}
}
@Injectable()
export class RuntimeProviderService {
private readonly logger = new Logger(RuntimeProviderService.name);
constructor(
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
private readonly registry: AgentRuntimeProviderRegistry,
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly audit: RuntimeAuditSink,
@Inject(RUNTIME_APPROVAL_VERIFIER)
private readonly approvals: RuntimeApprovalVerifier,
) {}
async capabilities(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeCapabilitySet> {
return this.execute(
providerId,
'runtime.capabilities',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
provider.capabilities(scope),
);
}
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
return this.execute(
providerId,
'runtime.health',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
provider.health(scope),
);
}
async transitionalCapabilityMatrix(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<TransitionalCapabilityInventoryEntry[]> {
return this.execute(
providerId,
'runtime.transitional-capabilities',
undefined,
undefined,
context,
async (provider: AgentRuntimeProvider, scope: RuntimeScope) => {
if (!isTransitionalInventoryProvider(provider)) {
throw new NotFoundException('Runtime provider has no transitional capability inventory');
}
return provider.transitionalCapabilityMatrix(scope);
},
);
}
async listSessions(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSession[]> {
return this.execute(
providerId,
'session.list',
'session.list',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
provider.listSessions(scope),
);
}
async getSessionTree(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSessionTree[]> {
return this.execute(
providerId,
'session.tree',
'session.tree',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
provider.getSessionTree(scope),
);
}
streamSession(
providerId: string,
sessionId: string,
cursor: string | undefined,
context: RuntimeProviderRequestContext,
): AsyncIterable<RuntimeStreamEvent> {
return this.stream(
providerId,
'session.stream',
'session.stream',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
provider.streamSession(sessionId, cursor, scope),
);
}
async sendMessage(
providerId: string,
sessionId: string,
message: RuntimeMessage,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.send',
'session.send',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.sendMessage(sessionId, message, scope),
);
}
async attach(
providerId: string,
sessionId: string,
mode: RuntimeAttachMode,
context: RuntimeProviderRequestContext,
): Promise<RuntimeAttachHandle> {
return this.execute(
providerId,
'session.attach',
'session.attach',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
provider.attach(sessionId, mode, scope),
);
}
async detach(
providerId: string,
attachmentId: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.attach',
'session.attach',
attachmentId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.detach(attachmentId, scope),
);
}
async terminate(
providerId: string,
sessionId: string,
approvalRef: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.terminate',
'session.terminate',
sessionId,
context,
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
const approved = await this.approvals.consume(approvalRef, {
providerId,
sessionId,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
agentName: configuredAgentName(),
});
if (!approved) {
throw new RuntimeApprovalDeniedError();
}
await provider.terminate(sessionId, approvalRef, scope);
},
);
}
private async execute<T>(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability | undefined,
resourceId: string | undefined,
context: RuntimeProviderRequestContext,
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
): Promise<T> {
const scope = this.deriveScope(context);
const startedAt = Date.now();
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
if (requiredCapability) {
await this.assertCapability(provider, requiredCapability, scope);
}
invocationStarted = true;
const result = await invoke(provider, scope);
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
return result;
} catch (error: unknown) {
const durationMs = Date.now() - startedAt;
if (invocationStarted && !this.isAuthorizationDenied(error)) {
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
} else {
await this.record(
providerId,
operation,
'denied',
scope,
resourceId,
durationMs,
'policy_denied',
);
}
throw error;
}
}
private async *stream(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability,
resourceId: string,
context: RuntimeProviderRequestContext,
invoke: (
provider: AgentRuntimeProvider,
scope: RuntimeScope,
) => AsyncIterable<RuntimeStreamEvent>,
): AsyncIterable<RuntimeStreamEvent> {
const scope = this.deriveScope(context);
const startedAt = Date.now();
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
await this.assertCapability(provider, requiredCapability, scope);
invocationStarted = true;
for await (const event of invoke(provider, scope)) {
yield event;
}
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
} catch (error: unknown) {
const durationMs = Date.now() - startedAt;
if (invocationStarted) {
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
} else {
await this.record(
providerId,
operation,
'denied',
scope,
resourceId,
durationMs,
'policy_denied',
);
}
throw error;
}
}
private isAuthorizationDenied(error: unknown): boolean {
return (
error instanceof RuntimeApprovalDeniedError ||
error instanceof ForbiddenException ||
(typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'forbidden')
);
}
private provider(providerId: string): AgentRuntimeProvider {
try {
return this.registry.require(providerId);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
throw new NotFoundException(message);
}
}
private async assertCapability(
provider: AgentRuntimeProvider,
requiredCapability: RuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
const capabilities = await provider.capabilities(scope);
if (!capabilities.supported.includes(requiredCapability)) {
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
}
}
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
const actorId = context.actorScope.userId.trim();
const tenantId = context.actorScope.tenantId.trim();
const channelId = context.channelId.trim();
const correlationId = context.correlationId.trim();
if (!actorId || !tenantId || !channelId || !correlationId) {
throw new ForbiddenException(
'Authenticated runtime actor scope and correlation are required',
);
}
return Object.freeze({ actorId, tenantId, channelId, correlationId });
}
private async recordFailure(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs: number,
): Promise<void> {
try {
await this.record(
providerId,
operation,
'failed',
scope,
resourceId,
durationMs,
'provider_error',
);
} catch {
this.logger.error(
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async recordCompletion(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs: number,
): Promise<void> {
try {
await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs);
} catch {
this.logger.error(
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async record(
providerId: string,
operation: RuntimeProviderOperation,
outcome: RuntimeProviderAuditOutcome,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs?: number,
errorCode?: RuntimeAuditErrorCode,
): Promise<void> {
await this.audit.record({
providerId,
operation,
outcome,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
...(resourceId ? { resourceId } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
...(errorCode ? { errorCode } : {}),
});
}
}
@@ -1,41 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { createMemoryTools } from './memory-tools.js';
describe('createMemoryTools operator retrieval binding', () => {
const memory = {
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
};
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
const plugin = {
search: vi.fn(async () => []),
capture: vi.fn(async () => ({ id: 'insight-1' })),
};
const tools = createMemoryTools(memory as never, null, 'owner-a', {
plugin: plugin as never,
scope,
});
await tools
.find((tool) => tool.name === 'memory_search')!
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
await tools
.find((tool) => tool.name === 'memory_save_insight')!
.execute(
'call-2',
{ content: 'secret', category: 'decision' },
undefined,
undefined,
{} as never,
);
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
expect(plugin.capture).toHaveBeenCalledWith(scope, {
content: 'secret',
source: 'agent',
category: 'decision',
});
});
});
+3 -29
View File
@@ -1,11 +1,7 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type {
EmbeddingProvider,
Memory,
OperatorMemoryPlugin,
OperatorMemoryScope,
} from '@mosaicstack/memory';
import type { Memory } from '@mosaicstack/memory';
import type { EmbeddingProvider } from '@mosaicstack/memory';
/**
* Create memory tools bound to the session's authenticated userId.
@@ -17,10 +13,8 @@ import type {
export function createMemoryTools(
memory: Memory,
embeddingProvider: EmbeddingProvider | null,
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
sessionUserId: string | undefined,
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
): ToolDefinition[] {
/** Return an error result when no session user is bound. */
function noUserError() {
@@ -52,14 +46,6 @@ export function createMemoryTools(
limit?: number;
};
if (operatorMemory) {
const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5);
return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
details: undefined,
};
}
if (!embeddingProvider) {
return {
content: [
@@ -172,18 +158,6 @@ export function createMemoryTools(
};
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
if (operatorMemory) {
const insight = await operatorMemory.plugin.capture(operatorMemory.scope, {
content,
source: 'agent',
category: category ?? 'learning',
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
details: undefined,
};
}
let embedding: number[] | null = null;
if (embeddingProvider) {
embedding = await embeddingProvider.embed(content);
-4
View File
@@ -1,4 +1,3 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto {
@@ -33,7 +32,4 @@ export class ChatSocketMessageDto {
@IsOptional()
@IsUUID()
agentId?: string;
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
}
@@ -1,74 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { ChatGateway } from './chat.gateway.js';
const payload: SlashCommandPayload = {
command: 'gc',
conversationId: 'conversation-1',
approvalId: 'approval-1',
};
function buildGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
commandExecutor as never,
{} as never,
);
}
describe('ChatGateway command approval ingress', () => {
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
createApproval: vi.fn(),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, payload);
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
userId: 'admin-1',
tenantId: 'admin-1',
});
expect(client.emit).toHaveBeenCalledWith(
'command:result',
expect.objectContaining({ success: true }),
);
});
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn(),
createApproval: vi.fn().mockResolvedValue({
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
}),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandApproval(client as never, {
command: 'gc',
conversationId: 'conversation-1',
});
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
{ command: 'gc', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith('command:approval', {
command: 'gc',
conversationId: 'conversation-1',
success: true,
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
});
});
});
@@ -1,221 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { ChatGateway } from './chat.gateway.js';
const CONVERSATION_ID = 'conversation-1';
const CANARY = 'sk_canary12345678';
function clientConversationKey(clientId: string, conversationId: string): string {
return `${clientId}\u0000${conversationId}`;
}
type GatewayInternals = {
clientSessions: Map<string, unknown>;
relayEvent(client: unknown, conversationId: string, event: unknown): void;
};
function buildGateway() {
const brain = {
conversations: {
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
return { gateway: gateway as unknown as GatewayInternals, brain };
}
describe('ChatGateway redaction boundary', (): void => {
it('redacts a secret split across assistant deltas before egress and persistence', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
const session = {
clientId: client.id,
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
};
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session);
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' },
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary');
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(session.assistantText).toBe(`${CANARY} `);
expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY);
});
it('retains a split secret label until its value can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'token ' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123');
});
it('holds a streamed private key until it can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET]',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary');
});
it('drops an oversized unterminated stream fragment rather than retaining it', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_STREAM_OVERFLOW]',
});
});
it('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 => {
const { gateway, brain } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), {
clientId: client.id,
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: CANARY,
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
});
gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' });
expect(brain.conversations.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: '[REDACTED_SECRET]',
metadata: expect.objectContaining({ classifications: ['secret'] }),
}),
'user-1',
);
expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY);
});
});
+45 -605
View File
@@ -1,5 +1,4 @@
import { createHash } from 'node:crypto';
import { Inject, Logger, Optional } from '@nestjs/common';
import { Inject, Logger } from '@nestjs/common';
import {
WebSocketGateway,
WebSocketServer,
@@ -14,32 +13,19 @@ import { Server, Socket } from 'socket.io';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import {
verifyDiscordIngressEnvelope,
parseDiscordInteractionBindings,
resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding,
type DiscordAttachment,
type DiscordIngressEnvelope,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import type { Auth } from '@mosaicstack/auth';
import type { Brain } from '@mosaicstack/brain';
import { redactSensitiveContent } from '@mosaicstack/log';
import type {
SetThinkingPayload,
SlashCommandApprovalResultPayload,
SlashCommandPayload,
SystemReloadPayload,
RoutingDecisionInfo,
AbortPayload,
ChannelAttachmentDto,
} from '@mosaicstack/types';
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
import {
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderService,
type RuntimeAuditSink,
} from '../agent/runtime-provider-registry.service.js';
import { DurableSessionService } from '../agent/durable-session.service.js';
import { AUTH } from '../auth/auth.tokens.js';
import {
scopeFromUser,
@@ -49,7 +35,6 @@ import {
import { BRAIN } from '../brain/brain.tokens.js';
import { CommandRegistryService } from '../commands/command-registry.service.js';
import { CommandExecutorService } from '../commands/command-executor.service.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
import { v4 as uuid } from 'uuid';
import { ChatSocketMessageDto } from './chat.dto.js';
@@ -58,7 +43,6 @@ import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
/** Per-client state tracking streaming accumulation for persistence. */
interface ClientSession {
clientId: string;
conversationId: string;
cleanup: () => void;
/** Accumulated assistant response text for the current turn. */
@@ -78,69 +62,6 @@ interface ClientSession {
* Keyed by conversationId, value is the model name to use.
*/
const modelOverrides = new Map<string, string>();
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 {
if (typeof value !== 'object' || value === null) return false;
@@ -153,49 +74,23 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo
return false;
}
const payload = envelope.payload as Record<string, unknown>;
return (
[
payload['correlationId'],
payload['messageId'],
payload['guildId'],
payload['channelId'],
payload['userId'],
payload['conversationId'],
payload['content'],
].every((field: unknown): boolean => typeof field === 'string') &&
(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)
);
return [
payload['correlationId'],
payload['messageId'],
payload['guildId'],
payload['channelId'],
payload['userId'],
payload['conversationId'],
payload['content'],
].every((field: unknown): boolean => typeof field === 'string');
}
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
if (typeof value !== 'object' || value === null) return false;
const payload = value as {
content?: unknown;
conversationId?: unknown;
attachments?: unknown;
};
const payload = value as { content?: unknown; conversationId?: unknown };
return (
typeof payload.content === 'string' &&
(payload.conversationId === undefined || typeof payload.conversationId === 'string') &&
(payload.attachments === undefined ||
hasValidAttachmentArray(payload.attachments, isChannelAttachment))
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
);
}
@@ -211,10 +106,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
private readonly logger = new Logger(ChatGateway.name);
private readonly clientSessions = new Map<string, ClientSession>();
/** Raw stream fragments are kept in memory only until they are safe to redact and emit. */
private readonly textEgressBuffers = new Map<string, string>();
private readonly thinkingEgressBuffers = new Map<string, string>();
private readonly overflowedEgress = new Set<string>();
private readonly discordReplayProtector = new DiscordReplayProtector();
constructor(
@@ -224,18 +115,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
@Optional()
@Inject(CommandAuthorizationService)
private readonly commandAuthorization: CommandAuthorizationService | null = null,
@Optional()
@Inject(RuntimeProviderService)
private readonly runtimeRegistry: RuntimeProviderService | null = null,
@Optional()
@Inject(DurableSessionService)
private readonly durableSessions: DurableSessionService | null = null,
@Optional()
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly runtimeAudit: RuntimeAuditSink | null = null,
) {}
afterInit(): void {
@@ -265,26 +144,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
handleDisconnect(client: Socket): void {
this.logger.log(`Client disconnected: ${client.id}`);
for (const [key, session] of this.clientSessions) {
if (session.clientId !== client.id) continue;
const session = this.clientSessions.get(client.id);
if (session) {
session.cleanup();
this.agentService.removeChannel(
session.conversationId,
`websocket:${client.id}`,
session.scope,
);
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.clientSessions.delete(client.id);
}
}
private clientConversationKey(client: Pick<Socket, 'id'>, conversationId: string): string {
return `${client.id}\u0000${conversationId}`;
}
private getClientScope(client: Socket): ActorTenantScope | null {
const user = client.data.user as AuthenticatedUserLike | undefined;
if (!user?.id) return null;
@@ -313,25 +184,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
discordIngress = this.resolveDiscordIngress(client, rawData);
if (!discordIngress) return;
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 }
: {}),
}),
),
}
: {}),
};
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
} else {
if (!isChatSocketMessage(rawData)) {
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
@@ -340,7 +193,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
data = rawData;
}
const conversationId = data.conversationId ?? uuid();
const clientConversationKey = this.clientConversationKey(client, conversationId);
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordIngress && !discordServiceUserId) {
this.logger.warn(
@@ -395,7 +247,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
this.logger.log(
`Using /model override "${modelOverride}" for conversation=${conversationId}`,
);
} else if (!resolvedProvider && !resolvedModelId && !discordIngress) {
} else if (!resolvedProvider && !resolvedModelId) {
// No explicit provider/model from client — use routing engine (M4-012)
try {
const routingDecision = await this.routingEngine.resolve(data.content, userId);
@@ -418,24 +270,12 @@ 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)
const sessionIdToCreate = existingSessionId ?? conversationId;
agentSession = await this.agentService.createSession(sessionIdToCreate, {
provider: resolvedProvider,
modelId: resolvedModelId,
agentConfigId: resolvedAgentConfigId,
agentConfigId: data.agentId,
userId,
tenantId: scope.tenantId,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
@@ -476,7 +316,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{
conversationId,
role: 'user',
content: redactSensitiveContent(data.content).content,
content: data.content,
metadata: {
timestamp: new Date().toISOString(),
...(correlationId
@@ -486,18 +326,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
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,
},
},
userId,
@@ -511,7 +339,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
// Always clean up previous listener to prevent leak
const existing = this.clientSessions.get(clientConversationKey);
const existing = this.clientSessions.get(client.id);
if (existing) {
existing.cleanup();
}
@@ -526,11 +354,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
);
// Preserve routing decision from the existing client session if we didn't get a new one
const prevClientSession = this.clientSessions.get(clientConversationKey);
const prevClientSession = this.clientSessions.get(client.id);
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
this.clientSessions.set(clientConversationKey, {
clientId: client.id,
this.clientSessions.set(client.id, {
conversationId,
cleanup,
assistantText: '',
@@ -576,7 +403,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
// Dispatch to agent
try {
await this.agentService.prompt(conversationId, data.content, scope, data.attachments);
await this.agentService.prompt(conversationId, data.content, scope);
} catch (err) {
this.logger.error(
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
@@ -694,30 +521,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
client.emit('command:result', result);
}
@SubscribeMessage('command:approve')
async handleCommandApproval(
@ConnectedSocket() client: Socket,
@MessageBody() payload: SlashCommandPayload,
): Promise<void> {
const scope = this.getClientScope(client);
const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null;
const result: SlashCommandApprovalResultPayload = approval
? {
command: payload.command,
conversationId: payload.conversationId,
success: true,
approvalId: approval.approvalId,
expiresAt: approval.expiresAt,
}
: {
command: payload.command,
conversationId: payload.conversationId,
success: false,
message: 'Not authorized to approve this command.',
};
client.emit('command:approval', result);
}
broadcastReload(payload: SystemReloadPayload): void {
this.server.emit('system:reload', payload);
this.logger.log('Broadcasted system:reload to all connected clients');
@@ -783,9 +586,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
};
// Emit to all clients currently subscribed to this conversation
for (const session of this.clientSessions.values()) {
for (const [clientId, session] of this.clientSessions) {
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
const socket = this.server.sockets.sockets.get(session.clientId);
const socket = this.server.sockets.sockets.get(clientId);
if (socket?.connected) {
socket.emit('session:info', payload);
}
@@ -798,173 +601,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
* Creates it if absent — safe to call concurrently since a duplicate insert
* would fail on the PK constraint and be caught here.
*/
@SubscribeMessage('discord:approve')
async handleDiscordApproval(
@ConnectedSocket() client: Socket,
@MessageBody() envelope: DiscordIngressEnvelope,
): Promise<void> {
if (!client.data.discordService) return;
const ingress = this.resolveDiscordIngress(client, envelope, 'approve');
const isApprovalCommand = /^\/approve\s*$/i.test(ingress?.content ?? '');
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
if (
!ingress ||
!isApprovalCommand ||
!tenantId ||
!this.commandAuthorization ||
!this.durableSessions
)
return;
const binding = this.discordBindingFor(ingress, 'approve');
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
const agentName = binding?.instanceId;
if (!actorId || !agentName) {
this.logger.warn(
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
);
client.emit('discord:approval', {
correlationId: ingress.correlationId,
success: false,
approvalId: undefined,
expiresAt: undefined,
});
return;
}
let snapshot;
try {
snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, {
actorScope: { userId: actorId, tenantId },
channelId: ingress.channelId,
correlationId: ingress.correlationId,
});
} catch {
client.emit('discord:approval', {
correlationId: ingress.correlationId,
success: false,
approvalId: undefined,
expiresAt: undefined,
});
return;
}
if (snapshot.identity.agentName !== agentName) {
client.emit('discord:approval', {
correlationId: ingress.correlationId,
success: false,
approvalId: undefined,
expiresAt: undefined,
});
return;
}
const approval = await this.commandAuthorization.createRuntimeTerminationApproval({
providerId: snapshot.identity.providerId,
sessionId: snapshot.identity.runtimeSessionId,
actorId,
tenantId,
channelId: ingress.channelId,
correlationId: this.discordRuntimeActionCorrelation(
binding.instanceId,
ingress,
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
),
agentName,
});
if (!approval) {
await this.runtimeAudit?.record({
providerId: snapshot.identity.providerId,
operation: 'session.terminate',
outcome: 'denied',
actorId,
tenantId,
channelId: ingress.channelId,
correlationId: this.discordRuntimeActionCorrelation(
binding.instanceId,
ingress,
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
),
resourceId: snapshot.identity.runtimeSessionId,
errorCode: 'policy_denied',
});
}
client.emit('discord:approval', {
correlationId: ingress.correlationId,
success: approval !== null,
approvalId: approval?.approvalId,
expiresAt: approval?.expiresAt,
});
}
@SubscribeMessage('discord:stop')
async handleDiscordStop(
@ConnectedSocket() client: Socket,
@MessageBody() envelope: DiscordIngressEnvelope,
): Promise<void> {
if (!client.data.discordService) return;
const ingress = this.resolveDiscordIngress(client, envelope, 'stop');
const approvalRef = /^\/stop\s+([^\s]+)$/i.exec(ingress?.content ?? '')?.[1];
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
return;
const binding = this.discordBindingFor(ingress, 'stop');
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
if (!actorId) return;
try {
const context = {
actorScope: { userId: actorId, tenantId },
channelId: ingress.channelId,
correlationId: ingress.correlationId,
};
const snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, context);
if (snapshot.identity.agentName !== binding.instanceId) throw new Error('agent mismatch');
// RuntimeProviderService consumes the durable approval exactly once using the
// provisioned approving-admin identity, never the Discord service account.
await this.runtimeRegistry.terminate(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
approvalRef,
{
...context,
correlationId: this.discordRuntimeActionCorrelation(
binding.instanceId,
ingress,
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
),
},
);
client.emit('discord:stop', { correlationId: ingress.correlationId, success: true });
} catch {
client.emit('discord:stop', { correlationId: ingress.correlationId, success: false });
}
}
/**
* Correlates the immutable termination target rather than either Discord message.
* Approval and stop are distinct ingress events, but must consume the same seven-field action.
*/
private discordRuntimeActionCorrelation(
instanceId: string,
ingress: DiscordIngressPayload,
providerId: string,
sessionId: string,
): string {
const target = [
instanceId,
ingress.guildId,
ingress.channelId,
ingress.conversationId,
providerId,
sessionId,
];
return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`;
}
private resolveDiscordIngress(
client: Socket,
envelope: DiscordIngressEnvelope,
operation: 'send' | 'approve' | 'stop' = 'send',
): DiscordIngressPayload | null {
const payload = verifyDiscordIngressEnvelope(
envelope,
@@ -979,25 +618,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
return null;
}
try {
const binding = this.discordBindingFor(payload, operation);
if (!binding) {
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
return null;
}
const expectedConversationId = `${binding.instanceId}:discord:${payload.threadId ?? payload.channelId}`;
if (payload.conversationId !== expectedConversationId) {
this.logger.warn(
`Rejected Discord ingress for a different logical agent from ${client.id}`,
);
return null;
}
} catch {
this.logger.warn(
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
);
return null;
}
if (!this.discordReplayProtector.claim(payload.messageId)) {
this.logger.warn(
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
@@ -1007,19 +627,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
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[] {
return (process.env[name] ?? '')
.split(',')
@@ -1098,15 +705,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const messages = await this.brain.conversations.findMessages(conversationId, userId);
if (messages.length === 0) return [];
return messages.map((msg) => {
const attachments = this.persistedChannelAttachments(msg.metadata);
return {
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
createdAt: msg.createdAt,
...(attachments ? { attachments } : {}),
};
});
return messages.map((msg) => ({
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
createdAt: msg.createdAt,
}));
} catch (err) {
this.logger.error(
`Failed to load conversation history for conversation=${conversationId}`,
@@ -1116,141 +719,6 @@ 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(
client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
buffers: Map<string, string>,
delta: string,
): void {
const sessionKey = this.clientConversationKey(client, conversationId);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) return;
const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`;
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
buffers.delete(sessionKey);
this.overflowedEgress.add(key);
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
return;
}
buffers.set(sessionKey, buffered);
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
}
/**
* Holds any suffix that could become a secret, email, or phone number after a
* later stream chunk. This avoids relying on downstream redaction after data
* has already reached the socket.
*/
private flushRedactedEgress(
client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
buffers: Map<string, string>,
final: boolean,
): void {
const sessionKey = this.clientConversationKey(client, conversationId);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) {
if (final) this.overflowedEgress.delete(key);
return;
}
const buffered = buffers.get(sessionKey) ?? '';
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
const released = buffered.slice(0, releaseLength);
const pending = buffered.slice(releaseLength);
if (pending) {
buffers.set(sessionKey, pending);
} else {
buffers.delete(sessionKey);
}
if (released) {
client.emit(eventName, {
conversationId,
text: redactSensitiveContent(released).content,
});
}
}
private safeRedactionPrefixLength(content: string): number {
let retainedFrom = content.length;
// Retain the current token because it may become a split secret or email.
const token = /(?:^|\s)(\S*)$/.exec(content);
if (token) {
const matched = token[0] ?? '';
const trailingToken = token[1] ?? '';
retainedFrom = token.index + matched.length - trailingToken.length;
}
// The secret classifier accepts whitespace around ':' and '=', so preserve
// a pending label until its value and delimiter are both complete.
const pendingSecretLabel =
/(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec(
content,
);
if (pendingSecretLabel) {
const label = pendingSecretLabel[1] ?? '';
retainedFrom = Math.min(
retainedFrom,
pendingSecretLabel.index + pendingSecretLabel[0].length - label.length,
);
}
const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec(
content,
);
if (secretLabel) {
retainedFrom = Math.min(retainedFrom, secretLabel.index);
}
// Phone numbers can contain whitespace and punctuation; preserve the full
// trailing numeric candidate until a non-phone character establishes a boundary.
const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content);
if (phone) {
const matched = phone[0] ?? '';
const trailingPhoneCandidate = phone[1] ?? '';
retainedFrom = Math.min(
retainedFrom,
phone.index + matched.length - trailingPhoneCandidate.length,
);
}
const privateKeyStart = content.lastIndexOf('-----BEGIN');
if (privateKeyStart >= 0) {
const privateKey = content.slice(privateKeyStart);
if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) {
// Release the complete block in one pass so the full-block classifier can redact it.
retainedFrom = content.length;
} else {
retainedFrom = Math.min(retainedFrom, privateKeyStart);
}
}
return retainedFrom;
}
private egressKey(
client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
): string {
return `${this.clientConversationKey(client, conversationId)}:${eventName}`;
}
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
if (!client.connected) {
this.logger.warn(
@@ -1259,27 +727,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return;
}
const sessionKey = this.clientConversationKey(client, conversationId);
switch (event.type) {
case 'agent_start': {
// Reset accumulation buffers for the new turn
const cs = this.clientSessions.get(sessionKey);
const cs = this.clientSessions.get(client.id);
if (cs) {
cs.assistantText = '';
cs.toolCalls = [];
cs.pendingToolCalls.clear();
}
this.textEgressBuffers.set(sessionKey, '');
this.thinkingEgressBuffers.set(sessionKey, '');
this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking'));
client.emit('agent:start', { conversationId });
break;
}
case 'agent_end': {
// Gather usage stats from the Pi session
const activeClientSession = this.clientSessions.get(sessionKey);
const activeClientSession = this.clientSessions.get(client.id);
const agentSession = activeClientSession
? this.agentService.getSession(conversationId, activeClientSession.scope)
: undefined;
@@ -1301,20 +764,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
: undefined;
this.flushRedactedEgress(
client,
conversationId,
'agent:text',
this.textEgressBuffers,
true,
);
this.flushRedactedEgress(
client,
conversationId,
'agent:thinking',
this.thinkingEgressBuffers,
true,
);
client.emit('agent:end', {
conversationId,
usage: usagePayload,
@@ -1332,7 +781,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
// Persist the assistant message with metadata
const cs = this.clientSessions.get(sessionKey);
const cs = this.clientSessions.get(client.id);
const userId = (client.data.user as { id: string } | undefined)?.id;
if (cs && userId && cs.assistantText.trim().length > 0) {
const metadata: Record<string, unknown> = {
@@ -1357,11 +806,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{
conversationId,
role: 'assistant',
content: redactSensitiveContent(cs.assistantText).content,
metadata: {
...metadata,
classifications: redactSensitiveContent(cs.assistantText).classifications,
},
content: cs.assistantText,
metadata,
},
userId,
)
@@ -1383,33 +829,27 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'message_update': {
const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type === 'text_delta') {
// Keep raw stream material in memory only; persist and emit only redacted text.
const cs = this.clientSessions.get(sessionKey);
// Accumulate assistant text for persistence
const cs = this.clientSessions.get(client.id);
if (cs) {
cs.assistantText += assistantEvent.delta;
}
this.appendAndFlushRedactedEgress(
client,
client.emit('agent:text', {
conversationId,
'agent:text',
this.textEgressBuffers,
assistantEvent.delta,
);
text: assistantEvent.delta,
});
} else if (assistantEvent.type === 'thinking_delta') {
this.appendAndFlushRedactedEgress(
client,
client.emit('agent:thinking', {
conversationId,
'agent:thinking',
this.thinkingEgressBuffers,
assistantEvent.delta,
);
text: assistantEvent.delta,
});
}
break;
}
case 'tool_execution_start': {
// Track pending tool call for later recording
const cs = this.clientSessions.get(sessionKey);
const cs = this.clientSessions.get(client.id);
if (cs) {
cs.pendingToolCalls.set(event.toolCallId, {
toolName: event.toolName,
@@ -1426,7 +866,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'tool_execution_end': {
// Finalise tool call record
const cs = this.clientSessions.get(sessionKey);
const cs = this.clientSessions.get(client.id);
if (cs) {
const pending = cs.pendingToolCalls.get(event.toolCallId);
cs.toolCalls.push({
@@ -1,116 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
const adminCommand: CommandDef = {
name: 'gc',
description: 'GC',
aliases: [],
scope: 'admin',
execution: 'socket',
available: true,
};
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
function createService(
role: string,
entries: Map<string, string> = new Map<string, string>(),
): CommandAuthorizationService {
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('CommandAuthorizationService', () => {
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(true);
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
const mutated = { ...payload, conversationId: 'other-conversation' };
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
const service = createService('member');
const approval = await service.createApproval(adminCommand, payload, 'member-1');
expect(approval).toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
).toBe(false);
});
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
providerId: 'fleet',
sessionId: 'nova',
actorId: 'admin-1',
tenantId: 'tenant-1',
channelId: 'discord:operator',
correlationId: 'correlation-malformed-expiry',
agentName: 'Nova',
};
const service = createService('admin', entries);
const approval = await service.createRuntimeTerminationApproval(action);
expect(approval).not.toBeNull();
const key = `agent:Nova:command-approval:${approval!.approvalId}`;
const stored = entries.get(key);
expect(stored).toBeDefined();
entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' }));
expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
false,
);
});
it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
providerId: 'fleet',
sessionId: 'nova',
actorId: 'admin-1',
tenantId: 'tenant-1',
channelId: 'discord:operator',
correlationId: 'correlation-1',
agentName: 'Nova',
};
const beforeRestart = createService('admin', entries);
const approval = await beforeRestart.createRuntimeTerminationApproval(action);
const afterRestart = createService('admin', entries);
expect(
await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, {
...action,
sessionId: 'forged-session',
}),
).toBe(false);
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
true,
);
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
false,
);
});
});
@@ -1,268 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
export type CommandRole = 'admin' | 'member' | 'viewer';
export interface CommandApproval {
approvalId: string;
actionDigest: string;
actorId: string;
command: string;
expiresAt: string;
}
/** Exact immutable binding for a privileged runtime termination. */
export interface RuntimeTerminationApprovalAction {
providerId: string;
sessionId: string;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
/** Provisioned roster identity; isolates approvals between interaction agents. */
agentName: string;
}
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
approvalId: string;
actionDigest: string;
expiresAt: string;
}
export interface CommandAuthorizationResult {
allowed: boolean;
reason?: string;
}
@Injectable()
export class CommandAuthorizationService {
constructor(
@Inject(DB) private readonly db: Db,
@Inject(COMMANDS_REDIS)
private readonly redis: {
get(key: string): Promise<string | null>;
set(key: string, value: string, ...args: string[]): Promise<unknown>;
del(key: string): Promise<number>;
},
) {}
async authorize(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
approvalId?: string,
): Promise<CommandAuthorizationResult> {
const role = await this.resolveRole(actorId);
if (!role || !this.hasScope(role, command.scope)) {
return { allowed: false, reason: 'not authorized for this command scope' };
}
if (command.scope !== 'admin') return { allowed: true };
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
const actionDigest = this.actionDigest(command.name, payload);
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
return approved
? { allowed: true }
: {
allowed: false,
reason: 'approval is invalid, expired, replayed, or does not match this action',
};
}
async createApproval(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
): Promise<CommandApproval | null> {
const role = await this.resolveRole(actorId);
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
const approvalId = randomUUID();
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
const approval: CommandApproval = {
approvalId,
actionDigest: this.actionDigest(command.name, payload),
actorId,
command: command.name,
expiresAt,
};
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
return approval;
}
/**
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
* command approvals. This deliberately avoids a parallel approval database.
*/
async createRuntimeTerminationApproval(
action: RuntimeTerminationApprovalAction,
): Promise<RuntimeTerminationApproval | null> {
if (!this.hasRuntimeTerminationAction(action)) return null;
const role = await this.resolveRole(action.actorId);
if (role !== 'admin') return null;
const approval: RuntimeTerminationApproval = {
approvalId: randomUUID(),
actionDigest: this.runtimeActionDigest(action),
...action,
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
};
await this.redis.set(
this.runtimeKey(action.agentName, approval.approvalId),
JSON.stringify(approval),
'EX',
'300',
);
return approval;
}
async consumeRuntimeTerminationApproval(
approvalId: string,
action: RuntimeTerminationApprovalAction,
): Promise<boolean> {
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
if (!encoded) return false;
let approval: unknown;
try {
approval = JSON.parse(encoded);
} catch {
return false;
}
if (
!this.isRuntimeTerminationApproval(approval) ||
approval.actionDigest !== this.runtimeActionDigest(action) ||
approval.actorId !== action.actorId ||
approval.tenantId !== action.tenantId ||
!this.isUnexpired(approval.expiresAt)
) {
return false;
}
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
}
private async resolveRole(actorId: string): Promise<CommandRole | null> {
const [user] = await this.db
.select({ role: usersTable.role })
.from(usersTable)
.where(eq(usersTable.id, actorId))
.limit(1);
const role = user?.role;
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (role === 'admin') return true;
return role === 'member' && (scope === 'core' || scope === 'agent');
}
private async consumeApproval(
approvalId: string,
actorId: string,
actionDigest: string,
): Promise<boolean> {
const key = this.key(approvalId);
const encoded = await this.redis.get(key);
if (!encoded) return false;
let parsed: unknown;
try {
parsed = JSON.parse(encoded);
} catch {
return false;
}
if (
!this.isCommandApproval(parsed) ||
parsed.actorId !== actorId ||
parsed.actionDigest !== actionDigest ||
!this.isUnexpired(parsed.expiresAt)
)
return false;
return (await this.redis.del(key)) === 1;
}
private actionDigest(command: string, payload: SlashCommandPayload): string {
return createHash('sha256')
.update(
JSON.stringify({
command,
args: payload.args?.trim() ?? '',
conversationId: payload.conversationId,
}),
)
.digest('hex');
}
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
return [
action.providerId,
action.sessionId,
action.actorId,
action.tenantId,
action.channelId,
action.correlationId,
action.agentName,
].every((value: string): boolean => value.trim().length > 0);
}
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
return createHash('sha256')
.update(
JSON.stringify({
providerId: action.providerId,
sessionId: action.sessionId,
actorId: action.actorId,
tenantId: action.tenantId,
channelId: action.channelId,
correlationId: action.correlationId,
agentName: action.agentName,
}),
)
.digest('hex');
}
private isUnexpired(expiresAt: unknown): expiresAt is string {
if (typeof expiresAt !== 'string') return false;
const expiresAtMs = Date.parse(expiresAt);
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
}
private isCommandApproval(value: unknown): value is CommandApproval {
return (
typeof value === 'object' &&
value !== null &&
'approvalId' in value &&
'actionDigest' in value &&
'actorId' in value &&
'expiresAt' in value &&
'command' in value
);
}
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
return (
typeof value === 'object' &&
value !== null &&
'approvalId' in value &&
'actionDigest' in value &&
'actorId' in value &&
'tenantId' in value &&
'providerId' in value &&
'sessionId' in value &&
'channelId' in value &&
'correlationId' in value &&
'agentName' in value &&
'expiresAt' in value
);
}
private key(approvalId: string): string {
return `interaction:command-approval:${approvalId}`;
}
private runtimeKey(agentName: string, approvalId: string): string {
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
}
}
@@ -72,13 +72,13 @@ const mockChatGateway = {
broadcastSessionInfo: vi.fn(),
};
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
function buildService(): CommandExecutorService {
return new CommandExecutorService(
mockRegistry as never,
mockAgentService as never,
mockSystemOverride as never,
mockSessionGC as never,
redis as never,
mockRedis as never,
mockBrain as never,
null,
mockChatGateway as never,
@@ -106,8 +106,8 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(result.command).toBe('provider');
});
// /provider login anthropic — no bearer token or auth URL reaches chat output
it('/provider login <name> keeps its one-time token out of chat output', async () => {
// /provider login anthropic — success with URL containing poll token
it('/provider login <name> returns success with URL and poll token', async () => {
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
@@ -117,9 +117,14 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(result.success).toBe(true);
expect(result.command).toBe('provider');
expect(result.message).toContain('anthropic');
expect(result.message).not.toContain('http');
expect(result.message).not.toContain('token=');
expect(result.data).toEqual({ provider: 'anthropic' });
expect(result.message).toContain('http');
// data should contain loginUrl and pollToken
expect(result.data).toBeDefined();
const data = result.data as Record<string, unknown>;
expect(typeof data['loginUrl']).toBe('string');
expect(typeof data['pollToken']).toBe('string');
expect(data['loginUrl'] as string).toContain('anthropic');
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
// Verify Valkey was called
expect(mockRedis.set).toHaveBeenCalledOnce();
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
@@ -131,22 +136,6 @@ describe('CommandExecutorService — P8-012 commands', () => {
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
it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId };
@@ -1,112 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
const registry = {
getManifest: vi.fn(() => ({
version: 1,
commands: [
{
name: 'gc',
description: 'System-wide garbage collection',
aliases: [],
scope: 'admin' as const,
execution: 'socket' as const,
available: true,
},
],
skills: [],
})),
};
const sessionGc = {
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
};
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
const authorization = {
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
Promise.resolve(
actorId === 'member-1'
? { allowed: false, reason: 'durable approval is required' }
: { allowed: false, reason: 'not authorized for this command scope' },
),
),
};
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
return new CommandExecutorService(
registry as never,
{ getSession: vi.fn() } as never,
{ clear: vi.fn(), set: vi.fn() } as never,
sessionGc as never,
{ set: vi.fn() } as never,
{ agents: {} } as never,
null,
null,
null,
authorizationService as never,
);
}
function createDurableAuthorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
beforeEach((): void => {
vi.clearAllMocks();
});
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
expect(result.success).toBe(false);
expect(result.message).toContain('not authorized');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('member-1'));
expect(result.success).toBe(false);
expect(result.message).toContain('approval');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
const executor = buildExecutor(createDurableAuthorization());
const adminScope = scope('admin-1');
const denied = await executor.execute(payload, adminScope);
const approval = await executor.createApproval(payload, adminScope);
const approved = await executor.execute(
{ ...payload, approvalId: approval?.approvalId },
adminScope,
);
expect(denied.success).toBe(false);
expect(denied.message).toContain('approval');
expect(approval).not.toBeNull();
// A valid durable approval is consumed, but cannot authorize an unimplemented
// global retention operation. Session-scoped cleanup remains lifecycle-only.
expect(approved.success).toBe(false);
expect(approved.message).toContain('Global GC is disabled');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
});
@@ -11,7 +11,6 @@ import { ReloadService } from '../reload/reload.service.js';
import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandRegistryService } from './command-registry.service.js';
@Injectable()
@@ -23,10 +22,7 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
@Inject(BRAIN) private readonly brain: Brain,
@Optional()
@Inject(forwardRef(() => ReloadService))
@@ -37,9 +33,6 @@ export class CommandExecutorService {
@Optional()
@Inject(McpClientService)
private readonly mcpClient: McpClientService | null,
@Optional()
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService | null = null,
) {}
async execute(
@@ -59,16 +52,6 @@ export class CommandExecutorService {
};
}
const authorization = await this.authorization?.authorize(
def,
payload,
userId,
payload.approvalId,
);
if (authorization && !authorization.allowed) {
return { command, conversationId, success: false, message: authorization.reason };
}
try {
switch (command) {
case 'model':
@@ -105,15 +88,16 @@ export class CommandExecutorService {
success: true,
message: 'Retry last message requested.',
};
case 'gc':
// Global retention requires a separate, authorized and audited job.
// Session cleanup is performed only through the session lifecycle.
case 'gc': {
// Admin-only: system-wide GC sweep across all sessions
const result = await this.sessionGC.sweepOrphans();
return {
command: 'gc',
success: false,
message: 'Global GC is disabled pending an authorized retention job.',
success: true,
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
conversationId,
};
}
case 'agent':
return await this.handleAgent(args ?? null, conversationId, scope);
case 'provider':
@@ -164,14 +148,6 @@ export class CommandExecutorService {
}
}
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
const def = this.registry
.getManifest()
.commands.find((command) => command.name === payload.command);
if (!def || !this.authorization) return null;
return this.authorization.createApproval(def, payload, scope.userId);
}
private async handleModel(
args: string | null,
conversationId: string,
@@ -438,30 +414,22 @@ export class CommandExecutorService {
};
}
const pollToken = crypto.randomUUID();
const tokenDigest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(pollToken),
const key = `mosaic:auth:poll:${pollToken}`;
// Store pending state in Valkey (TTL 5 minutes)
await this.redis.set(
key,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
byte.toString(16).padStart(2, '0'),
).join('');
const key = `mosaic:auth:poll:${tokenHash}`;
if (this.redis) {
// Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set(
key,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
}
// In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return {
command: 'provider',
success: true,
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
conversationId,
data: { provider: providerName },
data: { loginUrl, pollToken, provider: providerName },
};
}
@@ -177,12 +177,14 @@ describe('CommandExecutorService — integration', () => {
expect(result.command).toBe('nonexistent');
});
it('/gc refuses an unaudited global sweep', async () => {
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId };
const result = await executor.execute(payload, userScope);
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
expect(result.success).toBe(false);
expect(result.message).toContain('disabled pending an authorized retention job');
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
expect(result.success).toBe(true);
expect(result.message).toContain('GC sweep complete');
expect(result.message).toContain('3 orphaned sessions');
});
// /system with args calls SystemOverrideService.set
+6 -25
View File
@@ -1,14 +1,10 @@
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
import { CommandRegistryService } from './command-registry.service.js';
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
@@ -18,39 +14,24 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
providers: [
{
provide: COMMANDS_QUEUE_HANDLE,
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;
useFactory: (): QueueHandle => {
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
useFactory: (handle: QueueHandle) => handle.redis,
inject: [COMMANDS_QUEUE_HANDLE],
},
CommandRegistryService,
CommandAuthorizationService,
CommandRuntimeApprovalVerifier,
CommandExecutorService,
],
exports: [
CommandRegistryService,
CommandAuthorizationService,
CommandRuntimeApprovalVerifier,
CommandExecutorService,
],
exports: [CommandRegistryService, CommandExecutorService],
})
export class CommandsModule implements OnApplicationShutdown {
constructor(
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
await this.handle.close().catch(() => {});
}
}
@@ -1,23 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
RuntimeApprovalVerifier,
RuntimeTerminationAction,
} from '../agent/runtime-provider-registry.service.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
/**
* Adapter from the provider registry's exact termination action to the shared,
* Redis-backed `interaction:command-approval:*` store. It has no separate approval
* persistence or replay semantics.
*/
@Injectable()
export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
constructor(
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService,
) {}
async consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean> {
return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action);
}
}
+3 -25
View File
@@ -1,32 +1,10 @@
import { Module } from '@nestjs/common';
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
import { CoordService } from './coord.service.js';
import { CoordController } from './coord.controller.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
import {
COORDINATION_CONFIG,
COORDINATION_PORT,
InteractionCoordinationService,
} from './interaction-coordination.service.js';
@Module({
providers: [
CoordService,
{
provide: COORDINATION_PORT,
useFactory: (): InMemoryInteractionCoordinationPort =>
new InMemoryInteractionCoordinationPort(),
},
{
provide: COORDINATION_CONFIG,
useFactory: () => ({
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
}),
},
InteractionCoordinationService,
],
controllers: [CoordController, InteractionCoordinationController],
exports: [CoordService, InteractionCoordinationService],
providers: [CoordService],
controllers: [CoordController],
exports: [CoordService],
})
export class CoordModule {}
@@ -1,47 +0,0 @@
const PATH_METADATA = 'path';
import { describe, expect, it, vi } from 'vitest';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
const user = { id: 'operator-1', tenantId: 'tenant-a' };
describe('InteractionCoordinationController', () => {
it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => {
expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([
'api/coord/interaction',
'api/coord/mos',
]);
expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function');
expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function');
expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function');
});
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
const coordination = {
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
observe: vi.fn(),
result: vi.fn(),
};
const controller = new InteractionCoordinationController(coordination as never);
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
expect(coordination.handoff).toHaveBeenCalledWith(
{ idempotencyKey: 'request-1', summary: 'Implement' },
expect.objectContaining({
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
channelId: 'cli',
correlationId: 'corr-1',
}),
);
});
it('requires a correlation header before invoking the coordination service', async () => {
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
const controller = new InteractionCoordinationController(coordination as never);
await expect(
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
).rejects.toThrow('X-Correlation-Id is required');
expect(coordination.handoff).not.toHaveBeenCalled();
});
});
@@ -1,75 +0,0 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Inject,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type {
InteractionCoordinationObservationDto,
InteractionCoordinationResponseDto,
InteractionCoordinationResultDto,
CreateHandoffDto,
} from './interaction-coordination.dto.js';
import { InteractionCoordinationService } from './interaction-coordination.service.js';
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
@Controller(['api/coord/interaction', 'api/coord/mos'])
@UseGuards(AuthGuard)
export class InteractionCoordinationController {
constructor(
@Inject(InteractionCoordinationService)
private readonly coordination: InteractionCoordinationService,
) {}
@Post('handoff')
async handoff(
@Body() request: CreateHandoffDto,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<InteractionCoordinationResponseDto> {
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
}
@Get(':handoffId/observe')
async observe(
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<InteractionCoordinationObservationDto> {
return {
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
};
}
@Get(':handoffId/result')
async result(
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<InteractionCoordinationResultDto> {
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
}
private context(
user: AuthenticatedUserLike,
correlationId?: string,
): RuntimeProviderRequestContext {
const requestCorrelationId = correlationId?.trim();
if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required');
return {
actorScope: scopeFromUser(user),
channelId: 'cli',
correlationId: requestCorrelationId,
};
}
}
@@ -1,25 +0,0 @@
import type {
CoordinationObservation,
CoordinationResult,
HandoffReceipt,
} from '@mosaicstack/coord';
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
export interface CreateHandoffDto {
idempotencyKey: string;
summary: string;
context?: string;
missionId?: string;
}
export interface InteractionCoordinationResponseDto {
receipt: HandoffReceipt;
}
export interface InteractionCoordinationObservationDto {
observation: CoordinationObservation;
}
export interface InteractionCoordinationResultDto {
result: CoordinationResult;
}
@@ -1,88 +0,0 @@
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);
});
});
@@ -1,217 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import {
InMemoryInteractionCoordinationPort,
type InteractionCoordinationPort,
type Handoff,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import {
InteractionCoordinationService,
type InteractionCoordinationConfig,
type InteractionCoordinationGatewayError,
} from './interaction-coordination.service.js';
const context: RuntimeProviderRequestContext = {
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
channelId: 'cli',
correlationId: 'corr-1',
};
const config: InteractionCoordinationConfig = {
interactionAgentId: 'Nova',
orchestrationAgentId: 'Conductor',
};
function service(
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
options: {
config?: InteractionCoordinationConfig;
handoffIdFactory?: () => string;
} = {},
): InteractionCoordinationService {
return new InteractionCoordinationService(
port,
options.config ?? config,
options.handoffIdFactory ?? (() => 'handoff-1'),
);
}
describe('InteractionCoordinationService authority boundary', (): void => {
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = service(adapter);
await expect(
coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).resolves.toEqual({
handoffId: 'handoff-1',
targetAgentId: 'Conductor',
status: 'queued',
correlationId: 'corr-1',
});
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
const followUpContext = { ...context, correlationId: 'corr-2' };
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
targetAgentId: 'Conductor',
status: 'completed',
});
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
targetAgentId: 'Conductor',
status: 'completed',
summary: 'Merged by orchestrator',
});
});
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
});
await expect(
coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).rejects.toMatchObject({
code: 'unconfigured_requester',
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(handoff).not.toHaveBeenCalled();
});
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
});
await expect(
coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).rejects.toThrow('Interaction and orchestration identities must differ');
expect(handoff).not.toHaveBeenCalled();
});
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const observe = vi.spyOn(adapter, 'observe');
const result = vi.spyOn(adapter, 'result');
const coordination = service(adapter);
await coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
);
const otherTenant = {
...context,
actorScope: { ...context.actorScope, tenantId: 'tenant-b' },
};
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(observe).not.toHaveBeenCalled();
expect(result).not.toHaveBeenCalled();
});
it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise<void> => {
let handoffSequence = 0;
let release: (() => void) | undefined;
const delivered = new Promise<void>((resolve: () => void): void => {
release = resolve;
});
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => {
await delivered;
return {
handoffId: handoff.handoffId,
targetAgentId: handoff.targetAgentId,
status: 'queued' as const,
correlationId: handoff.scope.correlationId,
};
}),
observe: vi.fn(),
result: vi.fn(),
};
const coordination = service(adapter, {
handoffIdFactory: (): string => `handoff-${++handoffSequence}`,
});
const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' };
const first = coordination.handoff(request, context);
const retry = coordination.handoff(request, context);
expect(adapter.handoff).toHaveBeenCalledTimes(1);
release?.();
await expect(Promise.all([first, retry])).resolves.toEqual([
expect.objectContaining({ handoffId: 'handoff-1' }),
expect.objectContaining({ handoffId: 'handoff-1' }),
]);
await expect(
coordination.handoff(request, {
...context,
actorScope: { ...context.actorScope, userId: 'operator-2' },
}),
).resolves.toMatchObject({ handoffId: 'handoff-2' });
expect(adapter.handoff).toHaveBeenCalledTimes(2);
});
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter);
await coordination.handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
);
await expect(
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
).rejects.toMatchObject({
code: 'handoff_conflict',
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(handoff).toHaveBeenCalledTimes(1);
});
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => ({
handoffId: handoff.handoffId,
targetAgentId: 'Unexpected',
status: 'accepted' as const,
correlationId: handoff.scope.correlationId,
})),
observe: vi.fn(),
result: vi.fn(),
};
await expect(
service(adapter).handoff(
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
context,
),
).rejects.toMatchObject({ code: 'target_drift' });
});
});
@@ -1,303 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
InteractionCoordinationClient,
type CoordinationObservation,
type CoordinationResult,
type CoordinationScope,
type InteractionCoordinationIdentity,
type InteractionCoordinationPort,
type HandoffReceipt,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
const MAX_TRACKED_HANDOFFS = 1_000;
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
const MAX_SUMMARY_LENGTH = 2_048;
const MAX_CONTEXT_LENGTH = 8_192;
const MAX_MISSION_ID_LENGTH = 128;
export interface InteractionCoordinationConfig {
interactionAgentId?: string;
orchestrationAgentId?: string;
}
interface HandoffOwner {
actorId: string;
tenantId: string;
requesterAgentId: string;
correlationId: string;
expiresAt: number;
}
interface NormalizedHandoffRequest {
idempotencyKey: string;
summary: string;
context?: string;
missionId?: string;
}
interface TrackedHandoff {
request: NormalizedHandoffRequest;
receipt: Promise<HandoffReceipt>;
expiresAt: number;
}
/**
* Gateway authority boundary for the interaction agent. It derives requester,
* actor, and tenant from trusted server configuration and authentication; no
* channel request can name a target or gain orchestrator-owned orchestration verbs.
*/
@Injectable()
export class InteractionCoordinationService {
private readonly owners = new Map<string, HandoffOwner>();
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
constructor(
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {}
async handoff(
request: CreateHandoffDto,
context: RuntimeProviderRequestContext,
): Promise<HandoffReceipt> {
this.pruneExpiredTracking();
const normalized = this.normalizeRequest(request);
const scope = this.scope(context);
const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope);
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
if (existing !== undefined) {
if (!sameRequest(existing.request, normalized)) {
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Handoff idempotency key is already bound to different immutable input',
);
}
return existing.receipt;
}
const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope);
const tracked: TrackedHandoff = {
request: normalized,
receipt: pending,
expiresAt: this.expiresAt(),
};
this.handoffsByIdempotencyKey.set(idempotencyKey, tracked);
this.enforceTrackingLimit(this.handoffsByIdempotencyKey);
try {
return await pending;
} catch (error: unknown) {
if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) {
this.handoffsByIdempotencyKey.delete(idempotencyKey);
}
throw error;
}
}
async observe(
handoffId: string,
context: RuntimeProviderRequestContext,
): Promise<CoordinationObservation> {
this.pruneExpiredTracking();
const scope = this.scope(context);
const owner = this.ownerFor(handoffId, scope);
return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId });
}
async result(
handoffId: string,
context: RuntimeProviderRequestContext,
): Promise<CoordinationResult> {
this.pruneExpiredTracking();
const scope = this.scope(context);
const owner = this.ownerFor(handoffId, scope);
return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId });
}
private async deliverHandoff(
handoffId: string,
request: NormalizedHandoffRequest,
scope: CoordinationScope,
): Promise<HandoffReceipt> {
const receipt = await this.client((): string => handoffId).handoff(request, scope);
const owner: HandoffOwner = {
actorId: scope.actorId,
tenantId: scope.tenantId,
requesterAgentId: scope.requesterAgentId,
correlationId: scope.correlationId,
expiresAt: this.expiresAt(),
};
const existing = this.owners.get(receipt.handoffId);
if (existing !== undefined && !sameOwner(existing, owner)) {
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Handoff ID is already bound to a different authenticated scope',
);
}
this.owners.set(receipt.handoffId, owner);
this.enforceTrackingLimit(this.owners);
return receipt;
}
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
}
private identity(): InteractionCoordinationIdentity {
const interactionAgentId = this.config.interactionAgentId?.trim();
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
if (!interactionAgentId) {
throw new InteractionCoordinationGatewayError(
'unconfigured_requester',
'Interaction agent identity is not configured',
);
}
if (!orchestrationAgentId) {
throw new InteractionCoordinationGatewayError(
'unconfigured_target',
'Orchestration agent identity is not configured',
);
}
return { interactionAgentId, orchestrationAgentId };
}
private scope(context: RuntimeProviderRequestContext): CoordinationScope {
const identity = this.identity();
return Object.freeze({
actorId: context.actorScope.userId,
tenantId: context.actorScope.tenantId,
correlationId: context.correlationId,
requesterAgentId: identity.interactionAgentId,
});
}
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
if (typeof request !== 'object' || request === null) {
throw new InteractionCoordinationGatewayError(
'invalid_request',
'Handoff request is invalid',
);
}
const idempotencyKey = this.requiredString(
request.idempotencyKey,
'idempotency key',
MAX_IDEMPOTENCY_KEY_LENGTH,
);
const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH);
const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH);
const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH);
return Object.freeze({
idempotencyKey,
summary,
...(context === undefined ? {} : { context }),
...(missionId === undefined ? {} : { missionId }),
});
}
private idempotencyKey(requestKey: string, scope: CoordinationScope): string {
return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`;
}
private requiredString(value: unknown, field: string, maximumLength: number): string {
if (typeof value !== 'string') {
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Handoff ${field} must be a string`,
);
}
const normalized = value.trim();
if (normalized.length === 0 || normalized.length > maximumLength) {
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Handoff ${field} is invalid`,
);
}
return normalized;
}
private optionalString(value: unknown, field: string, maximumLength: number): string | undefined {
if (value === undefined) return undefined;
return this.requiredString(value, field, maximumLength);
}
private expiresAt(): number {
return Date.now() + HANDOFF_TRACKING_TTL_MS;
}
private pruneExpiredTracking(): void {
const now = Date.now();
for (const [key, tracked] of this.handoffsByIdempotencyKey) {
if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key);
}
for (const [key, owner] of this.owners) {
if (owner.expiresAt <= now) this.owners.delete(key);
}
}
private enforceTrackingLimit<T>(entries: Map<string, T>): void {
while (entries.size > MAX_TRACKED_HANDOFFS) {
const oldest = entries.keys().next().value;
if (typeof oldest !== 'string') return;
entries.delete(oldest);
}
}
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
const owner = this.owners.get(handoffId);
if (owner === undefined) {
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
}
if (
owner.tenantId !== scope.tenantId ||
owner.actorId !== scope.actorId ||
owner.requesterAgentId !== scope.requesterAgentId
) {
throw new InteractionCoordinationGatewayError(
'cross_tenant_forbidden',
'Handoff is outside the authenticated scope',
);
}
return owner;
}
}
export type InteractionCoordinationGatewayErrorCode =
| 'cross_tenant_forbidden'
| 'handoff_conflict'
| 'invalid_request'
| 'not_found'
| 'unconfigured_requester'
| 'unconfigured_target';
function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
return (
left.actorId === right.actorId &&
left.tenantId === right.tenantId &&
left.requesterAgentId === right.requesterAgentId
);
}
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
return (
left.idempotencyKey === right.idempotencyKey &&
left.summary === right.summary &&
left.context === right.context &&
left.missionId === right.missionId
);
}
export class InteractionCoordinationGatewayError extends Error {
constructor(
readonly code: InteractionCoordinationGatewayErrorCode,
message: string,
) {
super(message);
this.name = InteractionCoordinationGatewayError.name;
}
}
@@ -5,8 +5,6 @@ import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GetController } from './server/verbs/get.controller.js';
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({
controllers: [
EnrollmentController,
FederationController,
CapabilitiesController,
ListController,
GetController,
],
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
providers: [
AdminGuard,
CaService,
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
FederationGetQueryService,
],
exports: [
CaService,
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
FederationGetQueryService,
],
})
export class FederationModule {}
@@ -1,348 +0,0 @@
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',
});
});
});
@@ -1,207 +0,0 @@
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();
});
});
@@ -1,311 +0,0 @@
/**
* 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 };
}
}
@@ -1,100 +0,0 @@
/**
* 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 } };
}
}
+5 -15
View File
@@ -1,7 +1,5 @@
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js';
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [
{
provide: GC_QUEUE_HANDLE,
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;
useFactory: (): QueueHandle => {
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
useFactory: (handle: QueueHandle) => handle.redis,
inject: [GC_QUEUE_HANDLE],
},
SessionGCService,
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService],
})
export class GCModule implements OnApplicationShutdown {
constructor(
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
await this.handle.close().catch(() => {});
}
}
+43 -85
View File
@@ -3,7 +3,6 @@ import { Logger } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { SessionGCService } from './session-gc.service.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
type MockRedis = {
scan: ReturnType<typeof vi.fn>;
@@ -13,12 +12,7 @@ type MockRedis = {
describe('SessionGCService', () => {
let service: SessionGCService;
let mockRedis: MockRedis;
let mockLogService: {
logs: {
promoteSessionToWarm: ReturnType<typeof vi.fn>;
promoteToWarm: ReturnType<typeof vi.fn>;
};
};
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
/**
* Helper: build a scan mock that returns all provided keys in a single
@@ -36,7 +30,6 @@ describe('SessionGCService', () => {
mockLogService = {
logs: {
promoteSessionToWarm: vi.fn().mockResolvedValue(0),
promoteToWarm: vi.fn().mockResolvedValue(0),
},
};
@@ -66,89 +59,54 @@ describe('SessionGCService', () => {
expect(result.cleaned.valkeyKeys).toBeUndefined();
});
it('escapes glob metacharacters in a session identifier', async () => {
await service.collect('abc*?[tenant]\\escape');
expect(mockRedis.scan).toHaveBeenCalledWith(
'0',
'MATCH',
'mosaic:session:abc\\*\\?\\[tenant\\]\\\\escape:*',
'COUNT',
100,
);
});
it('preserves a valid durable approval after session GC', async () => {
const entries = new Map<string, string>();
const redis = {
scan: vi.fn().mockResolvedValue(['0', ['mosaic:session:owned:state']]),
get: vi.fn(async (key: string) => entries.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => entries.set(key, value)),
del: vi.fn(async (...keys: string[]) => {
let deleted = 0;
for (const key of keys) deleted += Number(entries.delete(key));
return deleted;
}),
};
const authorization = new CommandAuthorizationService(
{
select: () => ({
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
}),
} as never,
redis,
);
const command = {
name: 'gc',
description: 'System-wide garbage collection',
aliases: [],
scope: 'admin',
execution: 'socket',
available: true,
} as never;
const payload = { command: 'gc', conversationId: 'owned' };
const approval = await authorization.createApproval(command, payload, 'admin-1');
const approvalKey = `interaction:command-approval:${approval!.approvalId}`;
const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService);
await gc.collect('owned');
expect(entries.has(approvalKey)).toBe(true);
await expect(
authorization.authorize(command, payload, 'admin-1', approval!.approvalId),
).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 () => {
const result = await service.collect('test-session-id');
expect(result.sessionId).toBe('test-session-id');
});
it('collect() demotes logs only for the requested session', async () => {
await service.collect('owned-session');
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
'owned-session',
expect.any(Date),
);
expect(mockLogService.logs.promoteToWarm).not.toHaveBeenCalled();
it('fullCollect() deletes all session keys', async () => {
mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
const result = await service.fullCollect();
expect(mockRedis.del).toHaveBeenCalled();
expect(result.valkeyKeys).toBe(2);
});
it('does not expose automatic global GC entry points', () => {
expect('fullCollect' in service).toBe(false);
expect('sweepOrphans' in service).toBe(false);
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
mockRedis.scan = makeScanMock([]);
const result = await service.fullCollect();
expect(result.valkeyKeys).toBe(0);
expect(mockRedis.del).not.toHaveBeenCalled();
});
it('fullCollect() returns duration', async () => {
const result = await service.fullCollect();
expect(result.duration).toBeGreaterThanOrEqual(0);
});
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
// First scan call returns the global session list; subsequent calls return
// per-session keys during collect().
mockRedis.scan = vi
.fn()
.mockResolvedValueOnce([
'0',
['mosaic:session:abc:system', 'mosaic:session:abc:messages', 'mosaic:session:xyz:system'],
])
// collect('abc') scan
.mockResolvedValueOnce(['0', ['mosaic:session:abc:system', 'mosaic:session:abc:messages']])
// collect('xyz') scan
.mockResolvedValueOnce(['0', ['mosaic:session:xyz:system']]);
mockRedis.del.mockResolvedValue(1);
const result = await service.sweepOrphans();
expect(result.orphanedSessions).toBeGreaterThanOrEqual(0);
expect(result.duration).toBeGreaterThanOrEqual(0);
});
it('sweepOrphans() returns empty when no session keys', async () => {
mockRedis.scan = makeScanMock([]);
const result = await service.sweepOrphans();
expect(result.orphanedSessions).toBe(0);
expect(result.totalCleaned).toHaveLength(0);
});
});
+112 -22
View File
@@ -1,4 +1,4 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -13,29 +13,55 @@ export interface GCResult {
};
}
/** Escape Redis glob metacharacters so a session identifier is always literal. */
function escapeRedisGlobLiteral(value: string): string {
return value.replace(/[\\*?\[\]]/g, '\\$&');
export interface GCSweepResult {
orphanedSessions: number;
totalCleaned: GCResult[];
duration: number;
}
export interface FullGCResult {
valkeyKeys: number;
logsDemoted: number;
jobsPurged: number;
tempFilesRemoved: number;
duration: number;
}
@Injectable()
export class SessionGCService {
export class SessionGCService implements OnModuleInit {
private readonly logger = new Logger(SessionGCService.name);
constructor(
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
@Optional()
@Inject(REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
@Inject(LOG_SERVICE) private readonly logService: LogService,
) {}
onModuleInit(): void {
// Fire-and-forget: run full GC asynchronously so it does not block the
// NestJS bootstrap chain. Cold-start GC typically takes 100500 ms
// depending on Valkey key count; deferring it removes that latency from
// the TTFB of the first HTTP request.
this.fullCollect()
.then((result) => {
this.logger.log(
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
`${result.logsDemoted} logs demoted, ` +
`${result.jobsPurged} jobs purged, ` +
`${result.tempFilesRemoved} temp dirs removed ` +
`(${result.duration}ms)`,
);
})
.catch((err: unknown) => {
this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err));
});
}
/**
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes.
* Returns an empty population on the Local tier where Redis is disabled.
*/
private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = [];
let cursor = '0';
do {
@@ -52,23 +78,87 @@ export class SessionGCService {
async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
if (this.redis) {
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
// 1. Valkey: delete all session-scoped keys
const pattern = `mosaic:session:${sessionId}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
// 2. PG: demote hot-tier agent logs for this session only.
const cutoff = new Date();
const logsDemoted = await this.logService.logs.promoteSessionToWarm(sessionId, cutoff);
// 2. PG: demote hot-tier agent_logs for this session to warm
const cutoff = new Date(); // demote all hot logs for this session
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
if (logsDemoted > 0) {
result.cleaned.logsDemoted = logsDemoted;
}
return result;
}
/**
* Sweep GC find orphaned artifacts from dead sessions.
* System-wide operation: only call from admin-authorized paths or internal
* scheduled jobs. Individual session cleanup is handled by collect().
*/
async sweepOrphans(): Promise<GCSweepResult> {
const start = Date.now();
const cleaned: GCResult[] = [];
// 1. Find all session-scoped Valkey keys (non-blocking SCAN)
const allSessionKeys = await this.scanKeys('mosaic:session:*');
// Extract unique session IDs from keys
const sessionIds = new Set<string>();
for (const key of allSessionKeys) {
const match = key.match(/^mosaic:session:([^:]+):/);
if (match) sessionIds.add(match[1]!);
}
// 2. For each session ID, collect stale keys
for (const sessionId of sessionIds) {
const gcResult = await this.collect(sessionId);
if (Object.keys(gcResult.cleaned).length > 0) {
cleaned.push(gcResult);
}
}
return {
orphanedSessions: cleaned.length,
totalCleaned: cleaned,
duration: Date.now() - start,
};
}
/**
* Full GC aggressive collection for cold start.
* Assumes no sessions survived the restart.
*/
async fullCollect(): Promise<FullGCResult> {
const start = Date.now();
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
const sessionKeys = await this.scanKeys('mosaic:session:*');
if (sessionKeys.length > 0) {
await this.redis.del(...sessionKeys);
}
// 2. NOTE: channel keys are NOT collected on cold start
// (discord/telegram plugins may reconnect and resume)
// 3. PG: demote stale hot-tier logs older than 24h to warm
const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff);
// 4. No summarization job purge API available yet
const jobsPurged = 0;
return {
valkeyKeys: sessionKeys.length,
logsDemoted,
jobsPurged,
tempFilesRemoved: 0,
duration: Date.now() - start,
};
}
}
@@ -1,12 +0,0 @@
import { describe, expect, it } from 'vitest';
import { HealthController } from './health.controller.js';
describe('HealthController', (): void => {
it('exposes liveness and readiness without configuration details', (): void => {
const controller = new HealthController();
expect(controller.check()).toEqual({ status: 'ok' });
expect(controller.ready()).toEqual({ status: 'ready' });
expect(JSON.stringify(controller.ready())).not.toContain('credential');
});
});
@@ -6,10 +6,4 @@ export class HealthController {
check(): { status: string } {
return { status: 'ok' };
}
/** Readiness intentionally exposes no configuration, provider, or credential details. */
@Get('ready')
ready(): { status: string } {
return { status: 'ready' };
}
}
+14 -14
View File
@@ -6,10 +6,11 @@ import {
type OnModuleDestroy,
} from '@nestjs/common';
import { SummarizationService } from './summarization.service.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import {
QueueService,
QUEUE_GC,
QUEUE_SUMMARIZATION,
QUEUE_GC,
QUEUE_TIER_MANAGEMENT,
} from '../queue/queue.service.js';
import type { Worker } from 'bullmq';
@@ -18,22 +19,18 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
@Inject(QueueService) private readonly queueService: QueueService,
) {}
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 tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
// M6-003: Summarization repeatable job
await this.queueService.addRepeatableJob(
@@ -45,7 +42,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization();
});
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob(
@@ -57,14 +54,17 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement();
});
if (tierWorker) this.registeredWorkers.push(tierWorker);
this.registeredWorkers.push(tierWorker);
// Retire any repeatable global GC schedule created by older deployments.
// Session cleanup is now triggered only by an authorized session lifecycle operation.
await this.queueService.removeRepeatableJobs(QUEUE_GC, 'session-gc');
// M6-004: GC repeatable job
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
await this.sessionGC.sweepOrphans();
});
this.registeredWorkers.push(gcWorker);
this.logger.log(
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
);
}
+1 -21
View File
@@ -3,10 +3,8 @@ import {
createMemory,
type Memory,
createMemoryAdapter,
createOperatorMemoryPlugin,
type MemoryAdapter,
type MemoryConfig,
type OperatorMemoryPlugin,
} from '@mosaicstack/memory';
import type { Db } from '@mosaicstack/db';
import type { StorageAdapter } from '@mosaicstack/storage';
@@ -16,9 +14,6 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
import { MEMORY } from './memory.tokens.js';
import { MemoryController } from './memory.controller.js';
import { EmbeddingService } from './embedding.service.js';
import { redactSensitiveContent } from '@mosaicstack/log';
export const OPERATOR_MEMORY_PLUGIN = 'OPERATOR_MEMORY_PLUGIN';
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
@@ -43,24 +38,9 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter)
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
},
{
provide: OPERATOR_MEMORY_PLUGIN,
useFactory: (adapter: MemoryAdapter): OperatorMemoryPlugin | null => {
const instanceId = process.env['MOSAIC_OPERATOR_MEMORY_INSTANCE_ID']?.trim();
const namespace = process.env['MOSAIC_OPERATOR_MEMORY_NAMESPACE']?.trim();
if (!instanceId || !namespace) return null;
return createOperatorMemoryPlugin({
adapter,
instanceId,
namespace,
redact: (content) => redactSensitiveContent(content).content,
});
},
inject: [MEMORY_ADAPTER],
},
EmbeddingService,
],
controllers: [MemoryController],
exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService],
})
export class MemoryModule {}
@@ -1,141 +1,13 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
createDiscordIngressEnvelope,
verifyDiscordIngressEnvelope,
DiscordPlugin,
type DiscordIngressPayload,
parseDiscordInteractionBindings,
resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding,
} from '@mosaicstack/discord-plugin';
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
import { ChatGateway } from '../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
import { DiscordReplayProtector } from './discord-replay-protector.js';
const SERVICE_TOKEN = 'test-service-token';
const ENV_KEYS = [
'DISCORD_SERVICE_TOKEN',
'DISCORD_SERVICE_USER_ID',
'DISCORD_SERVICE_TENANT_ID',
'DISCORD_INTERACTION_BINDINGS',
'DISCORD_ALLOWED_GUILD_IDS',
'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
] as const;
const savedEnv = new Map<string, string | undefined>();
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': {
role: role === 'admin' ? 'admin' : 'operator',
mosaicUserId: 'mosaic-admin-001',
},
},
},
]);
}
afterEach((): void => {
for (const key of ENV_KEYS) {
const value = savedEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
savedEnv.clear();
});
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
const entries = new Map<string, string>();
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => entries.set(key, value),
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
function discordGateway(role: 'admin' | 'member'): {
gateway: ChatGateway;
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
consumedActions: Array<{ actorId: string; correlationId: string }>;
durable: { getSnapshot: ReturnType<typeof vi.fn> };
audit: { record: ReturnType<typeof vi.fn> };
} {
const authorization = commandAuthorization(role);
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const audit = { record: vi.fn().mockResolvedValue(undefined) };
const runtimeRegistry = new RuntimeProviderService(
{
require: () => ({
capabilities: async () => ({ supported: ['session.terminate'] }),
terminate: async () => undefined,
}),
} as never,
{ record: async () => undefined } as never,
{
consume: async (approvalId, action) => {
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
},
},
);
return {
gateway: new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
authorization,
runtimeRegistry,
durable as never,
audit as never,
),
client: { data: { discordService: true }, emit: vi.fn() },
consumedActions,
durable,
audit,
};
}
function ingressEnvelope(
content: string,
messageId: string,
overrides: Partial<DiscordIngressPayload> = {},
): ReturnType<typeof createDiscordIngressEnvelope> {
return createDiscordIngressEnvelope(
createPayload({ content, messageId, ...overrides }),
SERVICE_TOKEN,
);
}
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
return {
@@ -144,51 +16,13 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'Nova:discord:channel-001',
conversationId: 'discord-channel-001',
content: 'hello Tess',
...overrides,
};
}
describe('Discord ingress security', () => {
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
const [binding] = parseDiscordInteractionBindings(
JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': 'admin' },
},
]),
);
expect(
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
).toEqual(binding);
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
});
it('binds a differently named configured interaction instance without code changes', () => {
const binding = resolveDiscordInteractionBinding(
[
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
},
],
'guild-001',
'channel-001',
'user-001',
'send',
);
expect(binding?.instanceId).toBe('Nova');
});
it('accepts only the configured Discord service identity', () => {
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
@@ -252,464 +86,4 @@ describe('Discord ingress security', () => {
expect(replayProtector.claim('discord-message-003')).toBe(true);
expect(replayProtector.size).toBe(2);
});
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
configureDiscordEnv();
const { gateway, client, consumedActions } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'approve-message', {
correlationId: 'approval-ingress-correlation',
}),
);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
success: boolean;
};
expect(approval.success).toBe(true);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', {
correlationId: 'stop-ingress-correlation',
}),
);
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
correlationId: 'stop-ingress-correlation',
success: true,
});
expect(consumedActions).toEqual([
{
actorId: 'mosaic-admin-001',
correlationId: expect.stringMatching(/^discord-action:v1:/),
},
]);
});
it('audits a Discord mint-side authorization denial', async () => {
configureDiscordEnv();
const { gateway, client, audit } = discordGateway('member');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'denied-approve'),
);
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001',
success: false,
approvalId: undefined,
expiresAt: undefined,
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
outcome: 'denied',
operation: 'session.terminate',
errorCode: 'policy_denied',
}),
);
});
it('rejects approval when the durable session targets a different logical agent', async () => {
configureDiscordEnv();
const { gateway, client, durable } = discordGateway('admin');
durable.getSnapshot.mockResolvedValueOnce({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'mismatched-agent-approve'),
);
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001',
success: false,
approvalId: undefined,
expiresAt: undefined,
});
});
it('rejects privileged envelopes with a forged current conversation route', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'forged-approval-route', {
conversationId: 'Nova:discord:other-channel',
}),
);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope('/stop forged', 'forged-stop-route', {
conversationId: 'Nova:discord:other-channel',
}),
);
expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything());
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
});
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('member');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'member-approve'),
);
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001',
success: false,
approvalId: undefined,
expiresAt: undefined,
});
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope('/stop forged', 'unpaired-stop'),
);
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
});
it('rejects replaying a Discord-created termination approval', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'replay-approve', {
correlationId: 'replay-approval-correlation',
}),
);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
};
await gateway.handleDiscordStop(
client as never,
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', {
correlationId: 'replay-stop-correlation-one',
}),
);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', {
correlationId: 'replay-stop-correlation-two',
}),
);
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
true,
false,
]);
});
it.each([
'https://user:[email protected]/diagram.png',
'https://cdn.example.test/diagram.png?token=secret',
'https://cdn.example.test/diagram.png?X-Amz-Signature=secret',
'https://cdn.example.test/diagram.png?auth=secret',
'https://cdn.example.test/diagram.png?hm=secret',
])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleMessage(
client as never,
ingressEnvelope('', `credential-url-${url.length}`, {
conversationId: 'Nova:discord:channel-001',
attachments: [
{ id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' },
],
}),
);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
});
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
configureDiscordEnv();
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
{
instanceId: 'Orion',
agentConfigId: 'agent-config-orion',
guildId: 'guild-001',
channelId: 'channel-002',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
const brain = {
agents: {
findById: vi.fn((id: string) =>
Promise.resolve({
id,
name: id === 'agent-config-orion' ? 'Orion' : 'Nova',
}),
),
},
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const routingEngine = { resolve: vi.fn() };
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
routingEngine as never,
);
const client = {
id: 'discord-client-new-session',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('start configured session', 'configured-session-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
await gateway.handleMessage(
client as never,
ingressEnvelope('start second configured session', 'configured-session-002', {
channelId: 'channel-002',
conversationId: 'Orion:discord:channel-002',
}),
);
expect(createSession).toHaveBeenCalledWith(
'Nova:discord:channel-001',
expect.objectContaining({
agentConfigId: 'agent-config-nova',
userId: 'discord-service',
tenantId: 'tenant-discord',
}),
);
expect(createSession).toHaveBeenCalledWith(
'Orion:discord:channel-002',
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
);
expect(routingEngine.resolve).not.toHaveBeenCalled();
});
it('retains validated persisted attachments in resumed conversation history', async () => {
const attachment = {
id: 'attachment-history',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
mimeType: 'image/png',
sizeBytes: 4_096,
};
const gateway = new ChatGateway(
{} as never,
{} as never,
{
conversations: {
findMessages: vi.fn().mockResolvedValue([
{
role: 'user',
content: '',
createdAt: new Date('2026-07-14T12:00:00.000Z'),
metadata: { channelAttachments: [attachment] },
},
]),
},
} as never,
{} as never,
{} as never,
{} as never,
) as unknown as {
loadConversationHistory(
conversationId: string,
userId: string,
): Promise<Array<{ attachments?: readonly (typeof attachment)[] }>>;
};
await expect(
gateway.loadConversationHistory('Nova:discord:channel-001', 'discord-service'),
).resolves.toEqual([expect.objectContaining({ attachments: [attachment] })]);
});
it('rejects malformed signed attachment payloads before gateway dispatch', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
const malformedPayload: Record<string, unknown> = {
...createPayload({
messageId: 'malformed-attachments-001',
conversationId: 'Nova:discord:channel-001',
}),
attachments: { id: 'not-an-array' },
};
const envelope = createDiscordIngressEnvelope(
malformedPayload as unknown as DiscordIngressPayload,
SERVICE_TOKEN,
);
await gateway.handleMessage(client as never, envelope);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
});
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
configureDiscordEnv();
const prompt = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue(undefined);
const session = {
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(session),
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt,
};
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
const client = {
id: 'discord-client-001',
data: { discordService: true },
emit: vi.fn(),
};
const attachment = {
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
contentType: 'image/png',
sizeBytes: 4_096,
};
await gateway.handleMessage(
client as never,
ingressEnvelope('', 'attachment-message-001', {
conversationId: 'Nova:discord:channel-001',
attachments: [attachment],
}),
);
const expectedAttachment = {
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
sizeBytes: attachment.sizeBytes,
};
expect(prompt).toHaveBeenCalledWith(
'Nova:discord:channel-001',
'',
{ userId: 'discord-service', tenantId: 'tenant-discord' },
[expectedAttachment],
);
expect(addMessage).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 'Nova:discord:channel-001',
metadata: expect.objectContaining({ channelAttachments: [expectedAttachment] }),
}),
'discord-service',
);
});
it('accepts a thread message through its allowed bound parent channel', () => {
const emitted = vi.fn();
const plugin = new DiscordPlugin({
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
},
],
});
const internals = plugin as unknown as {
client: { user: { id: string } };
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
handleDiscordMessage(message: unknown): void;
};
internals.client = { user: { id: 'bot-001' } };
internals.socket = { connected: true, emit: emitted };
internals.handleDiscordMessage({
id: 'thread-message',
guildId: 'guild-001',
channelId: 'thread-001',
author: { id: 'user-001', bot: false },
mentions: { has: () => true },
content: '<@bot-001> hello from thread',
channel: { parentId: 'channel-001' },
attachments: new Map(),
});
const [, envelope] = emitted.mock.calls[0] as [
string,
ReturnType<typeof createDiscordIngressEnvelope>,
];
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
});
});
+1 -18
View File
@@ -6,7 +6,7 @@ import {
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
import { PluginService } from './plugin.service.js';
import type { IChannelPlugin } from './plugin.interface.js';
@@ -61,16 +61,6 @@ function requiredDiscordAllowlist(name: string): string[] {
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[] {
const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN'];
@@ -92,16 +82,9 @@ function createPluginRegistry(): IChannelPlugin[] {
guildId: discordGuildId,
gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken,
messageRateLimitPerMinute: optionalPositiveInteger(
'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE',
),
threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'),
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
interactionBindings: parseDiscordInteractionBindings(
process.env['DISCORD_INTERACTION_BINDINGS'],
),
}),
),
);
@@ -1,23 +0,0 @@
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,8 +1,6 @@
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
`${scope.tenantId}:${scope.userId}:${sessionId}`;
@@ -17,45 +15,16 @@ interface OverrideFragment {
addedAt: number;
}
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
@Injectable()
export class SystemOverrideService implements OnApplicationShutdown {
export class SystemOverrideService {
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>();
private readonly handle: QueueHandle;
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(() => {});
constructor() {
this.handle = createQueue();
}
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
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing
@@ -85,14 +54,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
}
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));
}
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) return;
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -100,11 +65,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
}
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(
SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
@@ -1,36 +0,0 @@
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.',
});
});
});
+6 -82
View File
@@ -8,9 +8,7 @@ import {
} from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaicstack/log';
import type { MosaicConfig } from '@mosaicstack/config';
import { LOG_SERVICE } from '../log/log.tokens.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import type { JobDto, JobStatus } from './queue-admin.dto.js';
// ---------------------------------------------------------------------------
@@ -110,42 +108,21 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly connection: ConnectionOptions;
private readonly queues = new Map<string, Queue<MosaicJobData>>();
private readonly workers = new Map<string, Worker<MosaicJobData>>();
/** False on Local tier — BullMQ/Redis operations become no-ops. */
private readonly enabled: boolean;
constructor(
@Optional()
@Inject(LOG_SERVICE)
private readonly logService: LogService | null,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.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;
this.connection = getConnection();
}
onModuleInit(): void {
if (this.enabled) {
this.logger.log('QueueService initialised (BullMQ)');
} else {
this.logger.log(
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
);
}
this.logger.log('QueueService initialised (BullMQ)');
}
async onModuleDestroy(): Promise<void> {
if (this.enabled) {
await this.closeAll();
}
await this.closeAll();
}
// -------------------------------------------------------------------------
@@ -154,10 +131,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Get or create a BullMQ Queue for the given queue name.
* Returns null on Local tier where BullMQ is disabled.
*/
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
if (!this.enabled) return null;
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
let queue = this.queues.get(name) as Queue<T> | undefined;
if (!queue) {
queue = new Queue<T>(name, { connection: this.connection });
@@ -169,7 +144,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Add a BullMQ repeatable job (cron-style).
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
* No-op on Local tier.
*/
async addRepeatableJob<T extends MosaicJobData>(
queueName: string,
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
data: T,
cronExpression: string,
): Promise<void> {
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return;
}
const queue = this.getQueue<T>(queueName)!;
const queue = this.getQueue<T>(queueName);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (queue as Queue<any>).add(jobName, data, {
repeat: { pattern: cronExpression },
@@ -194,45 +162,11 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
);
}
/**
* Remove every existing repeatable schedule for a job name. This supports
* safe retirement of previously registered system-wide jobs.
*/
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);
if (!queue) return 0;
const jobs = await queue.getRepeatableJobs();
const matchingJobs = jobs.filter((job) => job.name === jobName);
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
if (matchingJobs.length > 0) {
this.logger.log(
`Removed ${matchingJobs.length} repeatable "${jobName}" job(s) from "${queueName}"`,
);
}
return matchingJobs.length;
}
/**
* Register a Worker for the given queue name with error handling and
* exponential backoff.
* Returns null on Local tier where BullMQ is disabled.
*/
registerWorker<T extends MosaicJobData>(
queueName: string,
handler: JobHandler<T>,
): Worker<T> | null {
if (!this.enabled) {
this.logger.debug(
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
);
return null;
}
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
const worker = new Worker<T>(
queueName,
async (job) => {
@@ -289,12 +223,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Return queue health statistics for all managed queues.
* Returns an empty healthy result on Local tier.
*/
async getHealthStatus(): Promise<QueueHealthStatus> {
if (!this.enabled) {
return { queues: {}, healthy: true };
}
const queues: QueueHealthStatus['queues'] = {};
let healthy = true;
@@ -325,10 +255,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* List jobs across all managed queues, optionally filtered by status.
* BullMQ jobs are fetched by state type from each queue.
* Returns empty array on Local tier.
*/
async listJobs(status?: JobStatus): Promise<JobDto[]> {
if (!this.enabled) return [];
const jobs: JobDto[] = [];
const states: JobStatus[] = status
? [status]
@@ -355,10 +283,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
* job IDs are not globally unique they are scoped to their queue.
* Returns an error on Local tier.
*/
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const sep = compositeId.lastIndexOf('__');
if (sep === -1) {
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
@@ -390,7 +316,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Pause a queue by name.
*/
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.pause();
@@ -402,7 +327,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Resume a paused queue by name.
*/
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.resume();
-30
View File
@@ -1,30 +0,0 @@
<!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>
+2 -7
View File
@@ -3,10 +3,8 @@
"version": "0.0.2",
"private": true,
"scripts": {
"build": "node ../../scripts/build-web.mjs",
"build:vite": "vite build",
"build": "next build",
"dev": "next dev",
"dev:vite": "vite",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
@@ -21,7 +19,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2",
"socket.io-client": "^4.8.0",
"tailwind-merge": "^3.5.0"
},
@@ -31,11 +28,9 @@
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.5",
"jsdom": "^29.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.0",
"vite": "^8.2.1",
"vitest": "^3.2.7"
"vitest": "^2.0.0"
}
}
@@ -3,56 +3,41 @@
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { api } from '@/lib/api';
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
import { signIn } from '@/lib/auth-client';
import type { SsoProviderDiscovery } from '@/lib/sso';
import { getSsoProvider } from '@/lib/sso-providers';
export default function AuthProviderRedirectPage(): React.ReactElement {
const params = useParams<{ provider: string }>();
const searchParams = useSearchParams();
const providerId = typeof params.provider === 'string' ? params.provider : '';
const requestedCallbackURL = searchParams.get('callbackURL');
const [providerName, setProviderName] = useState<string | null>(null);
const provider = getSsoProvider(providerId);
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const currentProvider = provider;
if (!currentProvider) {
setError('Unknown SSO provider.');
return;
}
if (!currentProvider.enabled) {
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
return;
}
const activeProvider = currentProvider;
let cancelled = false;
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 result = await signIn.oauth2({
providerId: activeProvider.id,
callbackURL,
});
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.');
}
if (!cancelled && result?.error) {
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
}
}
@@ -61,22 +46,19 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
return () => {
cancelled = true;
};
}, [providerId, requestedCallbackURL]);
}, [callbackURL, provider]);
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}...`
{provider
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
: '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"
>
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
<p>{error}</p>
<Link
href="/login"
-57
View File
@@ -1,57 +0,0 @@
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,
});
});
});
+3 -1
View File
@@ -1,3 +1,5 @@
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
body?: unknown;
}
@@ -23,7 +25,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(path, {
const res = await fetch(`${GATEWAY_URL}${path}`, {
credentials: 'include',
...rest,
headers,
-29
View File
@@ -1,29 +0,0 @@
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 -2
View File
@@ -1,9 +1,8 @@
import { createAuthClient } from 'better-auth/react';
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({
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
plugins: [adminClient(), genericOAuthClient()],
});
-30
View File
@@ -1,30 +0,0 @@
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');
});
});
-23
View File
@@ -1,23 +0,0 @@
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;
}
}
-52
View File
@@ -1,52 +0,0 @@
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);
});
});
+3 -1
View File
@@ -1,10 +1,12 @@
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;
export function getSocket(): Socket {
if (!socket) {
socket = io('/chat', {
socket = io(`${GATEWAY_URL}/chat`, {
withCredentials: true,
autoConnect: false,
transports: ['websocket', 'polling'],
+48
View File
@@ -0,0 +1,48 @@
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();
});
});
+53
View File
@@ -0,0 +1,53 @@
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';
}
}
-19
View File
@@ -1,19 +0,0 @@
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>,
);
-49
View File
@@ -1,49 +0,0 @@
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);
}
-135
View File
@@ -1,135 +0,0 @@
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');
});
});
-23
View File
@@ -1,23 +0,0 @@
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 />;
}
-152
View File
@@ -1,152 +0,0 @@
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&apos;t have an account?{' '}
<Link to="/register" className="text-blue-400 hover:text-blue-300">
Sign up
</Link>
</p>
</div>
);
}
-116
View File
@@ -1,116 +0,0 @@
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>
);
}
@@ -1,90 +0,0 @@
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',
});
});
});
-91
View File
@@ -1,91 +0,0 @@
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>
);
}
-9
View File
@@ -1,9 +0,0 @@
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>
);
}
-58
View File
@@ -1,58 +0,0 @@
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);
},
);
});
-14
View File
@@ -1,14 +0,0 @@
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);
});
});
-23
View File
@@ -1,23 +0,0 @@
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,
},
});
}

Some files were not shown because too many files have changed in this diff Show More