Compare commits
48
Commits
3934e03fa6
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e00cc475a2 | ||
|
|
7d84e4ee03 | ||
|
|
4aaf41dd1a | ||
|
|
bf32f29acd | ||
|
|
1655b1579a | ||
|
|
e478a359eb | ||
|
|
76e4242cb1 | ||
|
|
00eb216480 | ||
|
|
d46a2d675a | ||
|
|
8c27024d0e | ||
|
|
48bb19310d | ||
|
|
406e40584d | ||
|
|
677aeb0c93 | ||
|
|
caebf9ef70 | ||
|
|
bd0ef2ab25 | ||
|
|
2d5a8c81ec | ||
|
|
884d527cc8 | ||
|
|
b2e005f2b4 | ||
|
|
87daa12976 | ||
|
|
b82a51da80 | ||
|
|
90cf286a09 | ||
|
|
0aef432052 | ||
|
|
41a16cc916 | ||
|
|
e16c08aa9f | ||
|
|
a34e92cf39 | ||
|
|
a4861c221f | ||
|
|
46d68e1ff4 | ||
|
|
c3496334a5 | ||
|
|
6f29d00149 | ||
|
|
068d0f9b1c | ||
|
|
13cd673d50 | ||
|
|
620cc608e0 | ||
|
|
91e692e3e7 | ||
|
|
b4753a75cd | ||
|
|
24bbd40dc7 | ||
|
|
4df478cdd1 | ||
|
|
b8844e1ff0 | ||
|
|
906ad8dc30 | ||
|
|
193331544d | ||
|
|
495f73bfdb | ||
|
|
b96cc7982a | ||
|
|
0883fb91ec | ||
|
|
56787fabf1 | ||
|
|
940ae3cc41 | ||
|
|
c25a551c28 | ||
|
|
94d6538061 | ||
|
|
a3c1ab923c | ||
|
|
838701bde2 |
@@ -1,49 +0,0 @@
|
|||||||
# C1 detector gate. The fixture itself is intentionally RED; CI is green only
|
|
||||||
# when its exact phase verdicts/reasons match the versioned expected-RED manifest.
|
|
||||||
when:
|
|
||||||
- event: [pull_request, manual]
|
|
||||||
- event: push
|
|
||||||
branch: [next, main]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
greenfield-git-present:
|
|
||||||
image: node:22-bookworm-slim
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
set +e
|
|
||||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
|
||||||
bash tools/e2e-install-test.sh --lane next --source checkout --git present \
|
|
||||||
> /tmp/greenfield-git-present.log 2>&1
|
|
||||||
fixture_status=$?
|
|
||||||
set -e
|
|
||||||
cat /tmp/greenfield-git-present.log
|
|
||||||
bash tools/verify-greenfield-expected-red.sh \
|
|
||||||
next-git-present /tmp/greenfield-git-present.log "$fixture_status"
|
|
||||||
|
|
||||||
greenfield-main-git-present:
|
|
||||||
image: node:22-bookworm-slim
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
set +e
|
|
||||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
|
||||||
bash tools/e2e-install-test.sh --lane main --source checkout --git present \
|
|
||||||
> /tmp/greenfield-main-git-present.log 2>&1
|
|
||||||
fixture_status=$?
|
|
||||||
set -e
|
|
||||||
cat /tmp/greenfield-main-git-present.log
|
|
||||||
bash tools/verify-greenfield-expected-red.sh \
|
|
||||||
main-git-present /tmp/greenfield-main-git-present.log "$fixture_status"
|
|
||||||
|
|
||||||
greenfield-git-absent:
|
|
||||||
image: node:22-bookworm-slim
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
set +e
|
|
||||||
MOSAIC_GREENFIELD_CONTAINER=1 \
|
|
||||||
bash tools/e2e-install-test.sh --lane next --source checkout --git absent \
|
|
||||||
> /tmp/greenfield-git-absent.log 2>&1
|
|
||||||
fixture_status=$?
|
|
||||||
set -e
|
|
||||||
cat /tmp/greenfield-git-absent.log
|
|
||||||
bash tools/verify-greenfield-expected-red.sh \
|
|
||||||
next-git-absent /tmp/greenfield-git-absent.log "$fixture_status"
|
|
||||||
+104
-5
@@ -1,5 +1,5 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs only on main branch push/tag
|
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||||
@@ -23,9 +23,21 @@ variables:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- '.woodpecker/**'
|
- '.woodpecker/**'
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: next
|
||||||
|
- &main_image_build_when
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude:
|
||||||
|
- 'packages/mosaic/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- '.woodpecker/**'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, next]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -103,6 +115,84 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
|
publish-next-npm:
|
||||||
|
image: *node_image
|
||||||
|
# Durable @next integration-line publish. Runs only on next; never writes
|
||||||
|
# the latest dist-tag and never commits the computed prerelease versions.
|
||||||
|
when:
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: next
|
||||||
|
environment:
|
||||||
|
NPM_TOKEN:
|
||||||
|
from_secret: gitea_token
|
||||||
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
|
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
||||||
|
commands:
|
||||||
|
- *enable_pnpm
|
||||||
|
- |
|
||||||
|
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
||||||
|
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
||||||
|
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||||
|
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||||
|
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||||
|
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||||
|
const roots = ['apps', 'packages', 'plugins'];
|
||||||
|
const updated = [];
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const packagePath = path.join(fullPath, 'package.json');
|
||||||
|
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||||
|
walk(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePackage(packagePath) {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||||
|
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||||
|
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||||
|
if (!stableMatch) {
|
||||||
|
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||||
|
}
|
||||||
|
const [, major, minor, patch] = stableMatch;
|
||||||
|
const oldVersion = manifest.version;
|
||||||
|
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||||
|
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const root of roots) walk(root);
|
||||||
|
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||||
|
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||||
|
for (const line of updated) console.log('[publish-next] ' + line);
|
||||||
|
NODE
|
||||||
|
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||||
|
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||||
|
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
||||||
|
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||||
|
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||||
|
depends_on:
|
||||||
|
- build
|
||||||
|
|
||||||
# TODO: Uncomment when ready to publish to npmjs.org
|
# TODO: Uncomment when ready to publish to npmjs.org
|
||||||
# publish-npmjs:
|
# publish-npmjs:
|
||||||
# image: *node_image
|
# image: *node_image
|
||||||
@@ -134,8 +224,17 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||||
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
|
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[publish] next gateway publish is sha-only"
|
||||||
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||||
|
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||||
|
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||||
@@ -146,7 +245,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -172,7 +271,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -11,48 +11,87 @@
|
|||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
|
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
|
||||||
|
|
||||||
## Package Map
|
### Stack
|
||||||
|
|
||||||
|
- **API:** NestJS with Fastify (`apps/gateway`)
|
||||||
|
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
||||||
|
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
||||||
|
- **Authentication:** BetterAuth (`packages/auth`)
|
||||||
|
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
||||||
|
- **Queue:** Valkey 8 (`packages/queue`)
|
||||||
|
- **Build:** pnpm workspaces and Turborepo
|
||||||
|
- **CI:** Woodpecker CI
|
||||||
|
- **Observability:** OpenTelemetry and Jaeger
|
||||||
|
|
||||||
|
### Package Map
|
||||||
|
|
||||||
| Package | Purpose | Key Dependencies |
|
| Package | Purpose | Key Dependencies |
|
||||||
| ------------------ | ------------------------------- | -------------------------------- |
|
| ------------------ | ----------------------------- | -------------------------------- |
|
||||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||||
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
||||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
| `packages/brain` | Structured data layer | @mosaicstack/db |
|
||||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
| `packages/queue` | Valkey task queue and MCP | ioredis |
|
||||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||||
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
|
||||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||||
|
|
||||||
## Architecture Rules
|
## Architecture and Code Conventions
|
||||||
|
|
||||||
1. Gateway is the single API surface — all clients connect through it
|
1. Gateway is the single API surface; all clients connect through it.
|
||||||
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
|
||||||
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
|
||||||
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
|
||||||
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
|
||||||
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata.
|
||||||
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
7. Keep DTOs in `*.dto.ts` files at module boundaries.
|
||||||
|
8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`.
|
||||||
|
9. Create a task-specific scratchpad for non-trivial work.
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
|
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d # Infrastructure
|
pnpm install --frozen-lockfile
|
||||||
pnpm install # Dependencies
|
pnpm preflight
|
||||||
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
|
||||||
|
# Optional local queue service only; do not start the full Compose stack.
|
||||||
|
docker compose up -d valkey
|
||||||
```
|
```
|
||||||
|
|
||||||
## Repo-Specific Notes
|
The pre-push hook requires:
|
||||||
|
|
||||||
- DTOs in `*.dto.ts` files at module boundaries
|
```bash
|
||||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
- NodeNext module resolution in all tsconfigs
|
```
|
||||||
- Scratchpads are mandatory for non-trivial tasks
|
|
||||||
|
Software delivery also requires the applicable tests. Common repository commands are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck # TypeScript checks across the workspace
|
||||||
|
pnpm lint # ESLint across the workspace
|
||||||
|
pnpm test # Checkout tests and package Vitest suites
|
||||||
|
pnpm format:check # Prettier check
|
||||||
|
pnpm build # Build all packages and applications
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database and Local Runtime Safety
|
||||||
|
|
||||||
|
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||||
|
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||||
|
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
|
||||||
|
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
|
||||||
|
- Migration artifact generation is offline and does not authorize PostgreSQL access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaicstack/db db:generate
|
||||||
|
```
|
||||||
|
|
||||||
## docs/TASKS.md — Schema (CANONICAL)
|
## docs/TASKS.md — Schema (CANONICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,5 @@
|
|||||||
# CLAUDE.md — Mosaic Stack
|
# Claude Compatibility Pointer
|
||||||
|
|
||||||
## Project
|
@AGENTS.md
|
||||||
|
|
||||||
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **API**: NestJS + Fastify adapter (`apps/gateway`)
|
|
||||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
|
||||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
|
||||||
- **Auth**: BetterAuth (`packages/auth`)
|
|
||||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
|
||||||
- **Queue**: Valkey 8 (`packages/queue`)
|
|
||||||
- **Build**: pnpm workspaces + Turborepo
|
|
||||||
- **CI**: Woodpecker CI
|
|
||||||
- **Observability**: OpenTelemetry → Jaeger
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm typecheck # TypeScript check (all packages)
|
|
||||||
pnpm lint # ESLint (all packages)
|
|
||||||
pnpm format:check # Prettier check
|
|
||||||
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.
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
|
||||||
- NodeNext module resolution
|
|
||||||
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
|
|
||||||
- DTOs in `*.dto.ts` files at module boundaries
|
|
||||||
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
|
|
||||||
- All three gates must pass before push: typecheck, lint, format:check
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
|||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the direct URL:
|
Or use the direct URL:
|
||||||
@@ -33,12 +33,12 @@ This installs both components:
|
|||||||
### Install lanes
|
### Install lanes
|
||||||
|
|
||||||
| Lane | Command | Use when | Source |
|
| 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` |
|
| 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 | Exact `@next` CLI/gateway versions + pinned `next` framework commit; pinned-source fallback |
|
| 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 |
|
| 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` selects the prerelease integration lane. It installs the exact CLI/gateway versions resolved from the aligned `@next` tags, and pins the framework archive to the resolved `next` commit. If the registry path fails, it builds from that pinned source. An explicit `--ref` or `MOSAIC_REF` wins and selects source mode.
|
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
@@ -48,13 +48,13 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Linux x86_64 with glibc (Debian is the greenfield CI platform; musl/Alpine, macOS, and ARM64 currently fail as unsupported)
|
- Node.js ≥ 20
|
||||||
- Node.js ≥ 20 and npm ≥ 9
|
- npm (for global @mosaicstack/mosaic install)
|
||||||
- `bash`, `curl`, `git`, `python3`, `tar`, and standard core utilities (`awk`, `df`, `find`, `flock`, `grep`, `install`, `realpath`, `sed`, `sha256sum`, `stat`, `sync`)
|
- One or more runtimes:
|
||||||
- At least 256 MiB free disk and 1,000 free inodes at the npm prefix
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
- [Codex](https://github.com/openai/codex)
|
||||||
|
- [OpenCode](https://opencode.ai)
|
||||||
The installer evaluates canonical phases P0–P9 and does not print `Done.` unless every committed postcondition passes. A failed phase exits non-zero, names the phase, and points to its durable journal under `${XDG_STATE_HOME:-~/.local/state}/mosaic/install/`. See [Installer state machine and recovery](docs/guides/installer-state-machine.md).
|
- [Pi](https://pi.dev)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -351,7 +351,7 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
|||||||
Run the installer again — it handles upgrades automatically:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash -o pipefail -c 'curl -fsSL https://mosaicstack.dev/install.sh | bash'
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the direct URL:
|
Or use the direct URL:
|
||||||
@@ -372,17 +372,17 @@ The CLI also performs a background update check on every invocation (cached for
|
|||||||
### Installer Flags
|
### Installer Flags
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash tools/install.sh --check # Side-effect-free P0-P8 postcondition check
|
bash tools/install.sh --check # Version check only
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --next # Prerelease lane: exact @next versions + pinned-source fallback
|
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 --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 (--ref wins over --next)
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. `--check` reports one PASS/FAIL row for each P0–P8 predicate and exits non-zero if any row fails; it does not create the npm prefix, lock, journal, manifest, or runtime files.
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
/**
|
||||||
|
* Federation M3 single-gateway integration tests (FED-M3-10).
|
||||||
|
*
|
||||||
|
* Covers MILESTONES.md M3 acceptance:
|
||||||
|
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
|
||||||
|
* - #7: max_rows_per_query caps list results.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - Real PostgreSQL via @mosaicstack/db.
|
||||||
|
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
|
||||||
|
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
|
||||||
|
*
|
||||||
|
* Run:
|
||||||
|
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
|
||||||
|
* src/__tests__/integration/federation-m3-list.integration.test.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import type { ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Test, type TestingModule } from '@nestjs/testing';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
createDb,
|
||||||
|
eq,
|
||||||
|
federationGrants,
|
||||||
|
federationPeers,
|
||||||
|
inArray,
|
||||||
|
missionTasks,
|
||||||
|
missions,
|
||||||
|
projects,
|
||||||
|
tasks,
|
||||||
|
teamMembers,
|
||||||
|
teams,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
users,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { DB } from '../../database/database.module.js';
|
||||||
|
import { GrantsService } from '../../federation/grants.service.js';
|
||||||
|
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
|
||||||
|
import { FederationScopeService } from '../../federation/server/scope.service.js';
|
||||||
|
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
|
||||||
|
import { ListController } from '../../federation/server/verbs/list.controller.js';
|
||||||
|
import {
|
||||||
|
makeMosaicIssuedCert,
|
||||||
|
makeSelfSignedCert,
|
||||||
|
} from '../../federation/__tests__/helpers/test-cert.js';
|
||||||
|
|
||||||
|
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||||
|
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||||
|
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
|
||||||
|
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
||||||
|
|
||||||
|
interface TestIds {
|
||||||
|
readonly subjectUserId: string;
|
||||||
|
readonly otherUserId: string;
|
||||||
|
readonly peerId: string;
|
||||||
|
readonly revokedPeerId: string;
|
||||||
|
readonly activeGrantId: string;
|
||||||
|
readonly revokedGrantId: string;
|
||||||
|
readonly subjectProjectId: string;
|
||||||
|
readonly subjectMissionId: string;
|
||||||
|
readonly otherProjectId: string;
|
||||||
|
readonly teamId: string;
|
||||||
|
readonly unauthorizedTeamId: string;
|
||||||
|
readonly teamProjectId: string;
|
||||||
|
readonly taskIds: readonly string[];
|
||||||
|
readonly excludedTaskIds: readonly string[];
|
||||||
|
readonly subjectNoteId: string;
|
||||||
|
readonly otherUserNoteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemToDer(pem: string): Buffer {
|
||||||
|
return Buffer.from(
|
||||||
|
pem
|
||||||
|
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||||
|
.replace(/-----END CERTIFICATE-----/, '')
|
||||||
|
.replace(/\s+/g, ''),
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFederationRequest(certPem: string): FastifyRequest {
|
||||||
|
return {
|
||||||
|
raw: {
|
||||||
|
socket: {
|
||||||
|
getPeerCertificate: () => ({
|
||||||
|
raw: pemToDer(certPem),
|
||||||
|
serialNumber: CERT_SERIAL_HEX,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGuardContext(request: FastifyRequest): {
|
||||||
|
readonly context: ExecutionContext;
|
||||||
|
readonly sent: { statusCode?: number; payload?: unknown };
|
||||||
|
} {
|
||||||
|
const sent: { statusCode?: number; payload?: unknown } = {};
|
||||||
|
const reply = {
|
||||||
|
status: (statusCode: number) => {
|
||||||
|
sent.statusCode = statusCode;
|
||||||
|
return {
|
||||||
|
header: () => ({
|
||||||
|
send: (payload: unknown) => {
|
||||||
|
sent.payload = payload;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as FastifyReply;
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => request,
|
||||||
|
getResponse: () => reply,
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
return { context, sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertUser(db: Db, id: string, label: string): Promise<void> {
|
||||||
|
await db.insert(users).values({
|
||||||
|
id,
|
||||||
|
name: `${RUN_ID}-${label}`,
|
||||||
|
email: `${RUN_ID}-${label}@federation-test.invalid`,
|
||||||
|
emailVerified: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedFixtures(db: Db): Promise<TestIds> {
|
||||||
|
const subjectUserId = `${RUN_ID}-subject`;
|
||||||
|
const otherUserId = `${RUN_ID}-other`;
|
||||||
|
const peerId = crypto.randomUUID();
|
||||||
|
const revokedPeerId = crypto.randomUUID();
|
||||||
|
const activeGrantId = crypto.randomUUID();
|
||||||
|
const revokedGrantId = crypto.randomUUID();
|
||||||
|
const subjectProjectId = crypto.randomUUID();
|
||||||
|
const subjectMissionId = crypto.randomUUID();
|
||||||
|
const otherProjectId = crypto.randomUUID();
|
||||||
|
const teamId = crypto.randomUUID();
|
||||||
|
const unauthorizedTeamId = crypto.randomUUID();
|
||||||
|
const teamProjectId = crypto.randomUUID();
|
||||||
|
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||||
|
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||||
|
const subjectNoteId = crypto.randomUUID();
|
||||||
|
const otherUserNoteId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await insertUser(db, subjectUserId, 'subject');
|
||||||
|
await insertUser(db, otherUserId, 'other');
|
||||||
|
|
||||||
|
await db.insert(teams).values([
|
||||||
|
{
|
||||||
|
id: teamId,
|
||||||
|
name: `${RUN_ID} allowed team`,
|
||||||
|
slug: `${RUN_ID}-allowed-team`,
|
||||||
|
ownerId: subjectUserId,
|
||||||
|
managerId: subjectUserId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: unauthorizedTeamId,
|
||||||
|
name: `${RUN_ID} unauthorized team`,
|
||||||
|
slug: `${RUN_ID}-unauthorized-team`,
|
||||||
|
ownerId: otherUserId,
|
||||||
|
managerId: otherUserId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(teamMembers).values([
|
||||||
|
{ teamId, userId: subjectUserId, role: 'member' },
|
||||||
|
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(projects).values([
|
||||||
|
{
|
||||||
|
id: subjectProjectId,
|
||||||
|
name: `${RUN_ID} subject personal project`,
|
||||||
|
ownerType: 'user',
|
||||||
|
ownerId: subjectUserId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherProjectId,
|
||||||
|
name: `${RUN_ID} other personal project`,
|
||||||
|
ownerType: 'user',
|
||||||
|
ownerId: otherUserId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: teamProjectId,
|
||||||
|
name: `${RUN_ID} unauthorized team project`,
|
||||||
|
ownerType: 'team',
|
||||||
|
teamId: unauthorizedTeamId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(missions).values({
|
||||||
|
id: subjectMissionId,
|
||||||
|
name: `${RUN_ID} subject mission`,
|
||||||
|
projectId: subjectProjectId,
|
||||||
|
userId: subjectUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(tasks).values([
|
||||||
|
{
|
||||||
|
id: taskIds[0],
|
||||||
|
title: `${RUN_ID} visible task 1`,
|
||||||
|
missionId: subjectMissionId,
|
||||||
|
createdAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: taskIds[1],
|
||||||
|
title: `${RUN_ID} visible task 2`,
|
||||||
|
projectId: subjectProjectId,
|
||||||
|
createdAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: taskIds[2],
|
||||||
|
title: `${RUN_ID} visible task 3`,
|
||||||
|
projectId: subjectProjectId,
|
||||||
|
createdAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: excludedTaskIds[0],
|
||||||
|
title: `${RUN_ID} other user task`,
|
||||||
|
projectId: otherProjectId,
|
||||||
|
createdAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: excludedTaskIds[1],
|
||||||
|
title: `${RUN_ID} unauthorized team task`,
|
||||||
|
projectId: teamProjectId,
|
||||||
|
createdAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(missionTasks).values([
|
||||||
|
{
|
||||||
|
id: subjectNoteId,
|
||||||
|
missionId: subjectMissionId,
|
||||||
|
userId: subjectUserId,
|
||||||
|
notes: `${RUN_ID} subject visible note`,
|
||||||
|
createdAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: otherUserNoteId,
|
||||||
|
missionId: subjectMissionId,
|
||||||
|
userId: otherUserId,
|
||||||
|
notes: `${RUN_ID} other user note on subject mission`,
|
||||||
|
createdAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(federationPeers).values([
|
||||||
|
{
|
||||||
|
id: peerId,
|
||||||
|
commonName: `${RUN_ID}-active-peer`,
|
||||||
|
displayName: `${RUN_ID} Active Peer`,
|
||||||
|
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||||
|
certSerial: CERT_SERIAL_HEX,
|
||||||
|
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||||
|
state: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: revokedPeerId,
|
||||||
|
commonName: `${RUN_ID}-revoked-peer`,
|
||||||
|
displayName: `${RUN_ID} Revoked Peer`,
|
||||||
|
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||||
|
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
|
||||||
|
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||||
|
state: 'active',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(federationGrants).values([
|
||||||
|
{
|
||||||
|
id: activeGrantId,
|
||||||
|
peerId,
|
||||||
|
subjectUserId,
|
||||||
|
status: 'active',
|
||||||
|
scope: {
|
||||||
|
resources: ['tasks', 'notes'],
|
||||||
|
excluded_resources: [],
|
||||||
|
filters: {
|
||||||
|
tasks: { include_personal: true, include_teams: [] },
|
||||||
|
notes: { include_personal: true, include_teams: [] },
|
||||||
|
},
|
||||||
|
max_rows_per_query: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: revokedGrantId,
|
||||||
|
peerId,
|
||||||
|
subjectUserId,
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: new Date(),
|
||||||
|
revokedReason: `${RUN_ID} revoked grant fixture`,
|
||||||
|
scope: {
|
||||||
|
resources: ['tasks'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subjectUserId,
|
||||||
|
otherUserId,
|
||||||
|
peerId,
|
||||||
|
revokedPeerId,
|
||||||
|
activeGrantId,
|
||||||
|
revokedGrantId,
|
||||||
|
subjectProjectId,
|
||||||
|
subjectMissionId,
|
||||||
|
otherProjectId,
|
||||||
|
teamId,
|
||||||
|
unauthorizedTeamId,
|
||||||
|
teamProjectId,
|
||||||
|
taskIds,
|
||||||
|
excludedTaskIds,
|
||||||
|
subjectNoteId,
|
||||||
|
otherUserNoteId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
|
||||||
|
if (!ids) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(missionTasks)
|
||||||
|
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(tasks)
|
||||||
|
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(missions)
|
||||||
|
.where(eq(missions.id, ids.subjectMissionId))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(projects)
|
||||||
|
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(teamMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(teamMembers.userId, ids.subjectUserId),
|
||||||
|
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(teams)
|
||||||
|
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(federationGrants)
|
||||||
|
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(federationPeers)
|
||||||
|
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
|
||||||
|
.catch(() => {});
|
||||||
|
await db
|
||||||
|
.delete(users)
|
||||||
|
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
|
||||||
|
let handle: DbHandle;
|
||||||
|
let db: Db;
|
||||||
|
let moduleRef: TestingModule;
|
||||||
|
let guard: FederationAuthGuard;
|
||||||
|
let listController: ListController;
|
||||||
|
let ids: TestIds | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
handle = createDb(PG_URL);
|
||||||
|
db = handle.db;
|
||||||
|
ids = await seedFixtures(db);
|
||||||
|
|
||||||
|
moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [ListController],
|
||||||
|
providers: [
|
||||||
|
{ provide: DB, useValue: db },
|
||||||
|
GrantsService,
|
||||||
|
FederationAuthGuard,
|
||||||
|
FederationScopeService,
|
||||||
|
FederationListQueryService,
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
guard = moduleRef.get(FederationAuthGuard);
|
||||||
|
listController = moduleRef.get(ListController);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||||
|
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||||
|
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
|
||||||
|
const malformedOidCert = await makeSelfSignedCert();
|
||||||
|
const request = makeFederationRequest(malformedOidCert);
|
||||||
|
const { context, sent } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||||
|
expect(sent.statusCode).toBe(401);
|
||||||
|
expect(sent.payload).toMatchObject({
|
||||||
|
error: {
|
||||||
|
code: 'unauthorized',
|
||||||
|
message: expect.stringContaining('missing required OID'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(request.federationContext).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
|
||||||
|
expect(ids).toBeDefined();
|
||||||
|
const revokedCert = await makeMosaicIssuedCert({
|
||||||
|
grantId: ids!.revokedGrantId,
|
||||||
|
subjectUserId: ids!.subjectUserId,
|
||||||
|
});
|
||||||
|
const request = makeFederationRequest(revokedCert);
|
||||||
|
const { context, sent } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||||
|
expect(sent.statusCode).toBe(403);
|
||||||
|
expect(sent.payload).toMatchObject({
|
||||||
|
error: {
|
||||||
|
code: 'forbidden',
|
||||||
|
message: 'Federation access denied',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(request.federationContext).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
|
||||||
|
expect(ids).toBeDefined();
|
||||||
|
const activeCert = await makeMosaicIssuedCert({
|
||||||
|
grantId: ids!.activeGrantId,
|
||||||
|
subjectUserId: ids!.subjectUserId,
|
||||||
|
});
|
||||||
|
const request = makeFederationRequest(activeCert);
|
||||||
|
const { context } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||||
|
|
||||||
|
const response = await listController.list('tasks', request, { limit: 100 });
|
||||||
|
const returnedIds = response.items.map((item) => item['id']);
|
||||||
|
|
||||||
|
expect(response.items).toHaveLength(2);
|
||||||
|
expect(response._truncated).toBe(true);
|
||||||
|
expect(response.nextCursor).toEqual(expect.any(String));
|
||||||
|
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
|
||||||
|
expect(returnedIds).not.toContain(ids!.taskIds[2]);
|
||||||
|
for (const excludedId of ids!.excludedTaskIds) {
|
||||||
|
expect(returnedIds).not.toContain(excludedId);
|
||||||
|
}
|
||||||
|
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes another user mission task notes on the same authorized mission', async () => {
|
||||||
|
expect(ids).toBeDefined();
|
||||||
|
const activeCert = await makeMosaicIssuedCert({
|
||||||
|
grantId: ids!.activeGrantId,
|
||||||
|
subjectUserId: ids!.subjectUserId,
|
||||||
|
});
|
||||||
|
const request = makeFederationRequest(activeCert);
|
||||||
|
const { context } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||||
|
|
||||||
|
const response = await listController.list('notes', request, { limit: 10 });
|
||||||
|
const returnedIds = response.items.map((item) => item['id']);
|
||||||
|
|
||||||
|
expect(returnedIds).toEqual([ids!.subjectNoteId]);
|
||||||
|
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
|
||||||
|
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for unsupported list resources', async () => {
|
||||||
|
expect(ids).toBeDefined();
|
||||||
|
const activeCert = await makeMosaicIssuedCert({
|
||||||
|
grantId: ids!.activeGrantId,
|
||||||
|
subjectUserId: ids!.subjectUserId,
|
||||||
|
});
|
||||||
|
const request = makeFederationRequest(activeCert);
|
||||||
|
const { context } = makeGuardContext(request);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||||
|
|
||||||
|
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'scope_violation',
|
||||||
|
message: 'Requested federation resource is not supported',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
||||||
import { sql, type Db } from '@mosaicstack/db';
|
import { sql, type Db } from '@mosaicstack/db';
|
||||||
import { createQueue } from '@mosaicstack/queue';
|
import { createQueue } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { ProviderService } from '../agent/provider.service.js';
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import { AdminGuard } from './admin.guard.js';
|
import { AdminGuard } from './admin.guard.js';
|
||||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||||
|
|
||||||
@@ -14,6 +16,9 @@ export class AdminHealthController {
|
|||||||
@Inject(DB) private readonly db: Db,
|
@Inject(DB) private readonly db: Db,
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(MOSAIC_CONFIG)
|
||||||
|
private readonly mosaicConfig: MosaicConfig | null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -55,6 +60,14 @@ export class AdminHealthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkCache(): Promise<ServiceStatusDto> {
|
private async checkCache(): Promise<ServiceStatusDto> {
|
||||||
|
// On Local tier there is no Redis. The cache is intentionally absent, which
|
||||||
|
// is a healthy state for this tier — report 'ok' rather than opening a new
|
||||||
|
// ioredis connection on every admin health check (which would spam
|
||||||
|
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
||||||
|
// signals "no cache backend to measure" for this tier.
|
||||||
|
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||||
|
return { status: 'ok', latencyMs: 0 };
|
||||||
|
}
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const handle = createQueue();
|
const handle = createQueue();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import * as nodeOs from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import * as nodeUrl from 'node:url';
|
||||||
|
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
|
||||||
|
interface ComposedModuleGraph {
|
||||||
|
imports: readonly unknown[];
|
||||||
|
federationModule: unknown;
|
||||||
|
bootLogLines: readonly string[];
|
||||||
|
mosaicConfig: MosaicConfig;
|
||||||
|
resolvedConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageTier = 'local' | 'standalone' | 'federated';
|
||||||
|
|
||||||
|
interface ModuleGraphFixture {
|
||||||
|
tempRoot: string;
|
||||||
|
anchor: string;
|
||||||
|
homePath: string;
|
||||||
|
cwdPath: string;
|
||||||
|
monorepoRootEnvPath: string;
|
||||||
|
gatewayLocalEnvPath: string;
|
||||||
|
daemonEnvPath: string;
|
||||||
|
monorepoRootConfigPath: string;
|
||||||
|
gatewayLocalConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModuleGraphFixtureOptions {
|
||||||
|
rootEnvMode?: 'present' | 'absent';
|
||||||
|
rootTier?: StorageTier;
|
||||||
|
rootEnvContents?: string;
|
||||||
|
redactionMarker?: string;
|
||||||
|
gatewayLocalTier?: StorageTier;
|
||||||
|
gatewayLocalEnvContents?: string;
|
||||||
|
daemonEnvContents?: string;
|
||||||
|
inheritedTier?: StorageTier;
|
||||||
|
expectedProcessTier?: string;
|
||||||
|
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||||
|
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||||
|
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
||||||
|
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
||||||
|
|
||||||
|
function configJson(tier: StorageTier): string {
|
||||||
|
if (tier === 'local') {
|
||||||
|
return JSON.stringify({
|
||||||
|
tier,
|
||||||
|
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||||
|
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
tier,
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||||
|
return { ...process.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in snapshot)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(snapshot)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||||
|
const relativePath = relative(tempRoot, path);
|
||||||
|
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigModuleProvider {
|
||||||
|
provide: string;
|
||||||
|
useFactory: () => MosaicConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('provide' in value) || typeof value.provide !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'useFactory' in value && typeof value.useFactory === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
||||||
|
expect(bootLogLines).toHaveLength(1);
|
||||||
|
const [bootLogLine] = bootLogLines;
|
||||||
|
if (bootLogLine === undefined) {
|
||||||
|
throw new Error('Expected a single boot log line');
|
||||||
|
}
|
||||||
|
|
||||||
|
return bootLogLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBootLogLine(
|
||||||
|
bootLogLines: readonly string[],
|
||||||
|
tier: StorageTier,
|
||||||
|
source: string,
|
||||||
|
): void {
|
||||||
|
const bootLogLine = singleBootLogLine(bootLogLines);
|
||||||
|
|
||||||
|
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
||||||
|
expect(bootLogLine).toContain(`source=${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModuleGraphFromDotenv(
|
||||||
|
options: ModuleGraphFixtureOptions,
|
||||||
|
): Promise<ComposedModuleGraph> {
|
||||||
|
const originalEnv = snapshotProcessEnv();
|
||||||
|
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
|
||||||
|
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||||
|
const homePath = join(tempRoot, 'home');
|
||||||
|
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
|
||||||
|
const fixture: ModuleGraphFixture = {
|
||||||
|
tempRoot,
|
||||||
|
anchor,
|
||||||
|
homePath,
|
||||||
|
cwdPath,
|
||||||
|
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
|
||||||
|
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
|
||||||
|
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
|
||||||
|
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
|
||||||
|
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
|
||||||
|
};
|
||||||
|
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||||
|
|
||||||
|
for (const path of Object.values(fixture)) {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(anchor, { recursive: true });
|
||||||
|
await mkdir(cwdPath, { recursive: true });
|
||||||
|
|
||||||
|
if ((options.rootEnvMode ?? 'present') === 'absent') {
|
||||||
|
if (
|
||||||
|
options.rootEnvContents !== undefined ||
|
||||||
|
options.rootTier !== undefined ||
|
||||||
|
options.redactionMarker !== undefined
|
||||||
|
) {
|
||||||
|
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
||||||
|
throw new Error('Expected rootTier or rootEnvContents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
||||||
|
const rootFixtureWithMarker = options.redactionMarker
|
||||||
|
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
|
||||||
|
: rootFixture;
|
||||||
|
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.daemonEnvContents !== undefined) {
|
||||||
|
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.gatewayLocalEnvContents !== undefined) {
|
||||||
|
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
|
||||||
|
} else if (options.gatewayLocalTier !== undefined) {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalEnvPath,
|
||||||
|
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
||||||
|
tempRoot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env['HOME'] = homePath;
|
||||||
|
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['VALKEY_URL'];
|
||||||
|
delete process.env['MOSAIC_GATEWAY_HOME'];
|
||||||
|
|
||||||
|
await options.setup?.(fixture);
|
||||||
|
|
||||||
|
if (options.inheritedTier !== undefined) {
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||||
|
vi.doMock('node:url', () => ({
|
||||||
|
...nodeUrl,
|
||||||
|
fileURLToPath: (url: string | URL): string => {
|
||||||
|
const actualPath = nodeUrl.fileURLToPath(url);
|
||||||
|
if (
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||||
|
) {
|
||||||
|
return join(anchor, 'env.ts');
|
||||||
|
}
|
||||||
|
return actualPath;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||||
|
|
||||||
|
if (options.inheritedTier === undefined) {
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envModule = await import('./env.js');
|
||||||
|
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
|
||||||
|
options.expectedProcessTier ?? options.rootTier,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { AppModule } = await import('./app.module.js');
|
||||||
|
const { FederationModule } = await import('./federation/federation.module.js');
|
||||||
|
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||||
|
|
||||||
|
if (!Array.isArray(imports)) {
|
||||||
|
throw new Error('AppModule imports metadata is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
|
||||||
|
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
|
||||||
|
|
||||||
|
if (!Array.isArray(providers)) {
|
||||||
|
throw new Error('ConfigModule providers metadata is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configProvider = providers
|
||||||
|
.filter(isConfigModuleProvider)
|
||||||
|
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
|
||||||
|
|
||||||
|
if (!configProvider) {
|
||||||
|
throw new Error('MOSAIC_CONFIG provider factory not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imports,
|
||||||
|
federationModule: FederationModule,
|
||||||
|
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
||||||
|
args.map((value: unknown): string => String(value)).join(' '),
|
||||||
|
),
|
||||||
|
mosaicConfig: configProvider.useFactory(),
|
||||||
|
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cwdSpy?.mockRestore();
|
||||||
|
vi.doUnmock('node:url');
|
||||||
|
vi.doUnmock('node:os');
|
||||||
|
vi.resetModules();
|
||||||
|
consoleInfoSpy?.mockRestore();
|
||||||
|
restoreProcessEnv(originalEnv);
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppModule federation gating', (): void => {
|
||||||
|
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
||||||
|
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
||||||
|
const envImportIndex = mainSource.indexOf("import './env.js';");
|
||||||
|
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
||||||
|
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
||||||
|
|
||||||
|
expect(envImportIndex).toBeGreaterThan(-1);
|
||||||
|
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
||||||
|
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores ambient cwd/.env and cwd/../.env files',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(
|
||||||
|
resolve(fixture.cwdPath, '..', '.env'),
|
||||||
|
'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores an ambient cwd/mosaic.config.json federated config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'ignores an ambient cwd/../../mosaic.config.json federated config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
let gatewayLocalConfigPath = '';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
let daemonConfigPath = '';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
|
||||||
|
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
|
||||||
|
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
|
||||||
|
await writeFixture(
|
||||||
|
fixture.gatewayLocalConfigPath,
|
||||||
|
configJson('standalone'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||||
|
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
|
||||||
|
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'logs mosaic.config.json when anchored config and env tiers are both federated',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'federated',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.monorepoRootConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'attributes an invalid monorepo-root dotenv tier to the default',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||||
|
expectedProcessTier: 'invalid',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||||
|
inheritedTier: 'local',
|
||||||
|
expectedProcessTier: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'daemon .env wins over monorepo-root and gateway-local tier values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
||||||
|
expectedProcessTier: 'standalone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
||||||
|
inheritedTier: 'standalone',
|
||||||
|
expectedProcessTier: 'standalone',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootEnvMode: 'absent',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
expectedProcessTier: 'federated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'monorepo-root .env wins over gateway-local tier values',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'standalone',
|
||||||
|
gatewayLocalTier: 'federated',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['local', 'standalone'] as const)(
|
||||||
|
'does not register FederationModule for the %s tier',
|
||||||
|
async (tier): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
|
||||||
|
|
||||||
|
expect(graph.imports).not.toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const redactionMarker = 'redaction-fixture-marker';
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'federated',
|
||||||
|
redactionMarker,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.imports).toContain(graph.federationModule);
|
||||||
|
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
||||||
|
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
tier: 'federated',
|
||||||
|
storage: {
|
||||||
|
type: 'postgres',
|
||||||
|
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
}),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('local');
|
||||||
|
expect(graph.mosaicConfig.storage).not.toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const graph = await loadModuleGraphFromDotenv({
|
||||||
|
rootTier: 'local',
|
||||||
|
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||||
|
await writeFixture(
|
||||||
|
fixture.monorepoRootConfigPath,
|
||||||
|
configJson('federated'),
|
||||||
|
fixture.tempRoot,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(graph.mosaicConfig.storage).toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -26,6 +26,16 @@ import { WorkspaceModule } from './workspace/workspace.module.js';
|
|||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { loadConfig } from '@mosaicstack/config';
|
||||||
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
|
||||||
|
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
||||||
|
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
||||||
|
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
||||||
|
// federation profile "must not start in non-federated dev"). The gateway
|
||||||
|
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
|
||||||
|
// configuration is visible here.
|
||||||
|
const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -53,7 +63,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
FederationModule,
|
...(federationEnabled ? [FederationModule] : []),
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
@@ -12,6 +13,7 @@ const mockRegistry = {
|
|||||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
],
|
],
|
||||||
skills: [],
|
skills: [],
|
||||||
})),
|
})),
|
||||||
@@ -72,17 +74,24 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildService(): CommandExecutorService {
|
function buildService(
|
||||||
|
redis: typeof mockRedis | null = mockRedis,
|
||||||
|
mcpClient: {
|
||||||
|
reconnectServer: ReturnType<typeof vi.fn>;
|
||||||
|
getServerStatuses: ReturnType<typeof vi.fn>;
|
||||||
|
getToolDefinitions: ReturnType<typeof vi.fn>;
|
||||||
|
} | null = null,
|
||||||
|
): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
mockSystemOverride as never,
|
mockSystemOverride as never,
|
||||||
mockSessionGC as never,
|
mockSessionGC as never,
|
||||||
mockRedis as never,
|
redis as never,
|
||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
null,
|
mcpClient as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +140,22 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(ttl).toBe(300);
|
expect(ttl).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('/provider login remains available without Redis on the local tier', async () => {
|
||||||
|
const localService = buildService(null);
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'provider',
|
||||||
|
args: 'login anthropic',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await localService.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).not.toContain('token=');
|
||||||
|
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||||
|
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// /provider with no args — returns usage
|
// /provider with no args — returns usage
|
||||||
it('/provider with no args returns usage message', async () => {
|
it('/provider with no args returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
@@ -242,4 +267,124 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('tools');
|
expect(result.message).toContain('tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
|
||||||
|
// Redis `set` inside /provider login is the only reachable path into the
|
||||||
|
// top-level catch in `execute()`. The raw exception must be logged
|
||||||
|
// server-side but never handed back to the socket client.
|
||||||
|
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
|
||||||
|
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
|
||||||
|
const rawError = new Error(distinctiveRawFailure);
|
||||||
|
const failingRedis = {
|
||||||
|
set: vi.fn().mockRejectedValue(rawError),
|
||||||
|
get: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
};
|
||||||
|
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
|
||||||
|
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'provider',
|
||||||
|
args: 'login anthropic',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await failingService.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.command).toBe('provider');
|
||||||
|
expect(result.message).toBe('Command failed due to an internal error.');
|
||||||
|
expect(result.message).not.toContain(distinctiveRawFailure);
|
||||||
|
expect(result.message).not.toContain('ECONNREFUSED');
|
||||||
|
|
||||||
|
// The real exception is still logged server-side, as the raw Error
|
||||||
|
// object itself (not stringified/interpolated into the log message).
|
||||||
|
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||||
|
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
|
||||||
|
expect(loggedRawError).toBe(true);
|
||||||
|
|
||||||
|
loggerErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inner catch sanitization (P3-5 operator ruling): every catch in
|
||||||
|
// command-executor.service.ts that returns a SlashCommandResultPayload
|
||||||
|
// must sanitize the client-facing message the same way the top-level
|
||||||
|
// catch does, while still logging the raw exception server-side.
|
||||||
|
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
|
||||||
|
const marker = new Error('distinctive-agent-create-failure-token-A17f');
|
||||||
|
mockBrain.agents.create.mockRejectedValueOnce(marker);
|
||||||
|
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'agent',
|
||||||
|
args: 'new my-new-agent',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.command).toBe('agent');
|
||||||
|
expect(result.message).toBe('Failed to create agent due to an internal error.');
|
||||||
|
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
|
||||||
|
|
||||||
|
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||||
|
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||||
|
expect(loggedRawError).toBe(true);
|
||||||
|
|
||||||
|
loggerErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
|
||||||
|
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
|
||||||
|
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
|
||||||
|
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'agent',
|
||||||
|
args: 'some-other-agent',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await service.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.command).toBe('agent');
|
||||||
|
expect(result.message).toBe('Failed to switch agent due to an internal error.');
|
||||||
|
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
|
||||||
|
|
||||||
|
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||||
|
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||||
|
expect(loggedRawError).toBe(true);
|
||||||
|
|
||||||
|
loggerErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
|
||||||
|
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
|
||||||
|
const mockMcpClient = {
|
||||||
|
reconnectServer: vi.fn().mockRejectedValue(marker),
|
||||||
|
getServerStatuses: vi.fn(() => []),
|
||||||
|
getToolDefinitions: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
const mcpService = buildService(mockRedis, mockMcpClient);
|
||||||
|
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'mcp',
|
||||||
|
args: 'reconnect my-server',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
const result = await mcpService.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.command).toBe('mcp');
|
||||||
|
expect(result.message).toBe(
|
||||||
|
'Failed to reconnect MCP server "my-server" due to an internal error.',
|
||||||
|
);
|
||||||
|
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
|
||||||
|
|
||||||
|
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||||
|
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||||
|
expect(loggedRawError).toBe(true);
|
||||||
|
|
||||||
|
loggerErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export class CommandExecutorService {
|
|||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
||||||
|
@Optional()
|
||||||
|
@Inject(COMMANDS_REDIS)
|
||||||
|
private readonly redis: QueueHandle['redis'] | null,
|
||||||
@Inject(BRAIN) private readonly brain: Brain,
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(forwardRef(() => ReloadService))
|
@Inject(forwardRef(() => ReloadService))
|
||||||
@@ -156,8 +159,13 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Command /${command} failed: ${err}`);
|
this.logger.error(`Command /${command} failed`, err);
|
||||||
return { command, conversationId, success: false, message: String(err) };
|
return {
|
||||||
|
command,
|
||||||
|
conversationId,
|
||||||
|
success: false,
|
||||||
|
message: 'Command failed due to an internal error.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,11 +341,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to create agent: ${err}`);
|
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to create agent: ${String(err)}`,
|
message: 'Failed to create agent due to an internal error.',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -388,11 +396,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
this.logger.error(`Failed to switch agent "${agentName}"`, err);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to switch agent: ${String(err)}`,
|
message: 'Failed to switch agent due to an internal error.',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -443,6 +451,7 @@ export class CommandExecutorService {
|
|||||||
byte.toString(16).padStart(2, '0'),
|
byte.toString(16).padStart(2, '0'),
|
||||||
).join('');
|
).join('');
|
||||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||||
|
if (this.redis) {
|
||||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
@@ -451,6 +460,7 @@ export class CommandExecutorService {
|
|||||||
'EX',
|
'EX',
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
@@ -603,11 +613,12 @@ export class CommandExecutorService {
|
|||||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
|
||||||
return {
|
return {
|
||||||
command: 'mcp',
|
command: 'mcp',
|
||||||
conversationId,
|
conversationId,
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import { ChatModule } from '../chat/chat.module.js';
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
@@ -16,13 +18,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
useFactory: (): QueueHandle => {
|
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||||
|
// On Local tier there is no Redis — skip the ioredis connection.
|
||||||
|
// CommandExecutorService falls back to no-cache for /provider login on local.
|
||||||
|
if (config?.queue?.type === 'local') return null;
|
||||||
return createQueue();
|
return createQueue();
|
||||||
},
|
},
|
||||||
|
inject: [MOSAIC_CONFIG],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: COMMANDS_REDIS,
|
provide: COMMANDS_REDIS,
|
||||||
useFactory: (handle: QueueHandle) => handle.redis,
|
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
@@ -38,9 +44,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
constructor(
|
||||||
|
@Optional()
|
||||||
|
@Inject(COMMANDS_QUEUE_HANDLE)
|
||||||
|
private readonly handle: QueueHandle | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle.close().catch(() => {});
|
await this.handle?.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { resolveGatewayConfigPath } from '../env.js';
|
||||||
|
|
||||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: MOSAIC_CONFIG,
|
provide: MOSAIC_CONFIG,
|
||||||
useFactory: (): MosaicConfig => loadConfig(),
|
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MOSAIC_CONFIG],
|
exports: [MOSAIC_CONFIG],
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
||||||
|
|
||||||
|
type TierSource =
|
||||||
|
| 'process environment'
|
||||||
|
| 'daemon .env'
|
||||||
|
| 'monorepo-root .env'
|
||||||
|
| 'gateway-local .env'
|
||||||
|
| 'default';
|
||||||
|
|
||||||
|
type BootSource = TierSource | 'mosaic.config.json';
|
||||||
|
|
||||||
|
export interface GatewayDotenvPaths {
|
||||||
|
daemonEnv: string;
|
||||||
|
monorepoRootEnv: string;
|
||||||
|
gatewayLocalEnv: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export function resolveGatewayDotenvPaths(
|
||||||
|
anchor: string = here,
|
||||||
|
homeBase: string = homedir(),
|
||||||
|
): GatewayDotenvPaths {
|
||||||
|
return {
|
||||||
|
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
||||||
|
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
||||||
|
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGatewayConfigPath(anchor: string = here): string {
|
||||||
|
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
||||||
|
const gatewayHome = resolve(
|
||||||
|
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
||||||
|
);
|
||||||
|
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
||||||
|
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
||||||
|
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||||
|
|
||||||
|
if (existsSync(daemonConfig)) {
|
||||||
|
return daemonConfig;
|
||||||
|
}
|
||||||
|
if (existsSync(gatewayLocalConfig)) {
|
||||||
|
return gatewayLocalConfig;
|
||||||
|
}
|
||||||
|
if (existsSync(monorepoRootConfig)) {
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return monorepoRootConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
|
||||||
|
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
|
||||||
|
anchor,
|
||||||
|
homeBase,
|
||||||
|
);
|
||||||
|
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
||||||
|
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
||||||
|
let databaseUrlSource: TierSource =
|
||||||
|
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
||||||
|
|
||||||
|
function loadAnchoredDotenv(
|
||||||
|
path: string,
|
||||||
|
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
||||||
|
): void {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
||||||
|
config({ path, quiet: true });
|
||||||
|
|
||||||
|
if (
|
||||||
|
beforeTier === undefined &&
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
||||||
|
tierSource === 'default'
|
||||||
|
) {
|
||||||
|
tierSource = sourceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
beforeDatabaseUrl === undefined &&
|
||||||
|
process.env['DATABASE_URL'] !== undefined &&
|
||||||
|
databaseUrlSource === 'default'
|
||||||
|
) {
|
||||||
|
databaseUrlSource = sourceLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .env from daemon config dir (global install / daemon mode) first.
|
||||||
|
// It takes precedence over file-based local-dev configuration.
|
||||||
|
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
||||||
|
|
||||||
|
// Load .env from the anchored monorepo root, then fill any remaining values
|
||||||
|
// from apps/gateway/.env when present.
|
||||||
|
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
||||||
|
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
||||||
|
|
||||||
|
const envOnlyTier = detectFromEnv().tier;
|
||||||
|
const configPath = resolveGatewayConfigPath(anchor);
|
||||||
|
const anchoredConfigExists = existsSync(configPath);
|
||||||
|
const resolvedTier = loadConfig(configPath).tier;
|
||||||
|
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
|
||||||
|
const recognizedTierDeterminesTier =
|
||||||
|
(configuredTier === 'federated' ||
|
||||||
|
configuredTier === 'standalone' ||
|
||||||
|
configuredTier === 'local') &&
|
||||||
|
configuredTier === envOnlyTier;
|
||||||
|
|
||||||
|
let source: BootSource;
|
||||||
|
if (anchoredConfigExists) {
|
||||||
|
source = 'mosaic.config.json';
|
||||||
|
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
||||||
|
source = databaseUrlSource;
|
||||||
|
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
||||||
|
source = tierSource;
|
||||||
|
} else {
|
||||||
|
source = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGatewayEnv();
|
||||||
@@ -5,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js';
|
|||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||||
|
import { GetController } from './server/verbs/get.controller.js';
|
||||||
|
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||||
@@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js';
|
|||||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
controllers: [
|
||||||
|
EnrollmentController,
|
||||||
|
FederationController,
|
||||||
|
CapabilitiesController,
|
||||||
|
ListController,
|
||||||
|
GetController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
createPgliteDb,
|
||||||
|
missionTasks,
|
||||||
|
missions,
|
||||||
|
projects,
|
||||||
|
runPgliteMigrations,
|
||||||
|
teams,
|
||||||
|
users,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||||
|
import { FederationGetQueryService } from '../get-query.service.js';
|
||||||
|
|
||||||
|
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
|
||||||
|
resource: 'credentials',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
includePersonal: true,
|
||||||
|
teamIds: [],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUBJECT_USER_ID = 'fed-m3-06-subject';
|
||||||
|
const OTHER_USER_ID = 'fed-m3-06-other';
|
||||||
|
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
|
||||||
|
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
|
||||||
|
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
|
||||||
|
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
|
||||||
|
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
|
||||||
|
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
|
||||||
|
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
|
||||||
|
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
|
||||||
|
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
|
||||||
|
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
|
||||||
|
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
|
||||||
|
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
|
||||||
|
|
||||||
|
let dbHandle: DbHandle | undefined;
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
return new FederationGetQueryService({} as Db);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDbService() {
|
||||||
|
if (!dbHandle) {
|
||||||
|
throw new Error('test DB not initialized');
|
||||||
|
}
|
||||||
|
return new FederationGetQueryService(dbHandle.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedNotesFixture() {
|
||||||
|
if (!dbHandle) {
|
||||||
|
throw new Error('test DB not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbHandle.db.insert(users).values([
|
||||||
|
{
|
||||||
|
id: SUBJECT_USER_ID,
|
||||||
|
name: 'Federation Subject',
|
||||||
|
email: `${SUBJECT_USER_ID}@example.test`,
|
||||||
|
emailVerified: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OTHER_USER_ID,
|
||||||
|
name: 'Federation Other',
|
||||||
|
email: `${OTHER_USER_ID}@example.test`,
|
||||||
|
emailVerified: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(teams).values([
|
||||||
|
{
|
||||||
|
id: TEAM_ID,
|
||||||
|
name: 'FED-M3-06 Team',
|
||||||
|
slug: 'fed-m3-06-team',
|
||||||
|
ownerId: SUBJECT_USER_ID,
|
||||||
|
managerId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNAUTHORIZED_TEAM_ID,
|
||||||
|
name: 'FED-M3-06 Unauthorized Team',
|
||||||
|
slug: 'fed-m3-06-unauthorized-team',
|
||||||
|
ownerId: OTHER_USER_ID,
|
||||||
|
managerId: OTHER_USER_ID,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(projects).values([
|
||||||
|
{
|
||||||
|
id: PERSONAL_PROJECT_ID,
|
||||||
|
name: 'FED-M3-06 Personal Project',
|
||||||
|
ownerId: SUBJECT_USER_ID,
|
||||||
|
ownerType: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TEAM_PROJECT_ID,
|
||||||
|
name: 'FED-M3-06 Team Project',
|
||||||
|
teamId: TEAM_ID,
|
||||||
|
ownerType: 'team',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNAUTHORIZED_PROJECT_ID,
|
||||||
|
name: 'FED-M3-06 Unauthorized Project',
|
||||||
|
teamId: UNAUTHORIZED_TEAM_ID,
|
||||||
|
ownerType: 'team',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(missions).values([
|
||||||
|
{
|
||||||
|
id: PERSONAL_MISSION_ID,
|
||||||
|
name: 'FED-M3-06 Personal Mission',
|
||||||
|
projectId: PERSONAL_PROJECT_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TEAM_MISSION_ID,
|
||||||
|
name: 'FED-M3-06 Team Mission',
|
||||||
|
projectId: TEAM_PROJECT_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: UNAUTHORIZED_MISSION_ID,
|
||||||
|
name: 'FED-M3-06 Unauthorized Mission',
|
||||||
|
projectId: UNAUTHORIZED_PROJECT_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dbHandle.db.insert(missionTasks).values([
|
||||||
|
{
|
||||||
|
id: SUBJECT_TEAM_NOTE_ID,
|
||||||
|
missionId: TEAM_MISSION_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
notes: 'subject note on team mission',
|
||||||
|
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: OTHER_TEAM_NOTE_ID,
|
||||||
|
missionId: TEAM_MISSION_ID,
|
||||||
|
userId: OTHER_USER_ID,
|
||||||
|
notes: 'other user note on team mission',
|
||||||
|
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||||
|
missionId: PERSONAL_MISSION_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
notes: 'subject note on personal mission',
|
||||||
|
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||||
|
missionId: UNAUTHORIZED_MISSION_ID,
|
||||||
|
userId: SUBJECT_USER_ID,
|
||||||
|
notes: 'subject note outside grant-visible missions',
|
||||||
|
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FederationGetQueryService', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
|
||||||
|
await runPgliteMigrations(dbHandle);
|
||||||
|
await seedNotesFixture();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await dbHandle?.close();
|
||||||
|
dbHandle = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.evaluateReadAccess({
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
resource: 'credentials',
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
allowed: false,
|
||||||
|
reason: 'credentials federation get access is not implemented in M3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows personal memory reads without requiring team lookup', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.evaluateReadAccess({
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
resource: 'memory',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
allowed: true,
|
||||||
|
access: { includePersonal: true, teamIds: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
|
||||||
|
}
|
||||||
|
).listSubjectTeamIds = listSubjectTeamIds;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.evaluateReadAccess({
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
resource: 'tasks',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
allowed: true,
|
||||||
|
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||||
|
});
|
||||||
|
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
|
||||||
|
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'credentials federation get is not implemented',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for unsupported resources instead of returning undefined', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
...CREDENTIAL_FILTER,
|
||||||
|
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||||
|
},
|
||||||
|
id: 'row-1',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Unsupported federation get resource: unknown-resource',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak another user mission task note through team-scoped get reads', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: false,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
id: OTHER_TEAM_NOTE_ID,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Note is outside the federated scope',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: true,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Note is outside the federated scope',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: false,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
id: SUBJECT_TEAM_NOTE_ID,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
status: 'found',
|
||||||
|
item: {
|
||||||
|
id: SUBJECT_TEAM_NOTE_ID,
|
||||||
|
missionId: TEAM_MISSION_ID,
|
||||||
|
content: 'subject note on team mission',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return subject personal notes when includePersonal is false', async () => {
|
||||||
|
const service = makeDbService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.get({
|
||||||
|
filter: {
|
||||||
|
resource: 'notes',
|
||||||
|
subjectUserId: SUBJECT_USER_ID,
|
||||||
|
includePersonal: false,
|
||||||
|
teamIds: [TEAM_ID],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 10,
|
||||||
|
},
|
||||||
|
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Note is outside the federated scope',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { RequestMethod } from '@nestjs/common';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||||
|
import type {
|
||||||
|
FederationScopeEvaluationResult,
|
||||||
|
FederationScopeQueryFilter,
|
||||||
|
} from '../../scope.service.js';
|
||||||
|
import { GetController } from '../get.controller.js';
|
||||||
|
import type { FederationGetQueryResult } from '../get-query.service.js';
|
||||||
|
|
||||||
|
const FEDERATION_CONTEXT = {
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||||
|
resource: 'tasks',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
includePersonal: true,
|
||||||
|
teamIds: ['team-1'],
|
||||||
|
limit: 1,
|
||||||
|
maxRowsPerQuery: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeRequest(): FastifyRequest {
|
||||||
|
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedScope(
|
||||||
|
filter: FederationScopeQueryFilter = TASK_FILTER,
|
||||||
|
): FederationScopeEvaluationResult {
|
||||||
|
return { allowed: true, filter };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeController(opts?: {
|
||||||
|
scopeResult?: FederationScopeEvaluationResult;
|
||||||
|
queryResult?: FederationGetQueryResult;
|
||||||
|
}) {
|
||||||
|
const scope = {
|
||||||
|
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||||
|
};
|
||||||
|
const query = {
|
||||||
|
evaluateReadAccess: vi.fn(),
|
||||||
|
get: vi.fn().mockResolvedValue(
|
||||||
|
opts?.queryResult ?? {
|
||||||
|
status: 'found',
|
||||||
|
item: {
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Federated task',
|
||||||
|
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
controller: new GetController(scope as never, query as never),
|
||||||
|
scope,
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GetController', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
|
||||||
|
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
|
||||||
|
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
|
||||||
|
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
|
||||||
|
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
|
||||||
|
const { controller, scope, query } = makeController();
|
||||||
|
|
||||||
|
const response = await controller.get('tasks', 'task-1', makeRequest());
|
||||||
|
|
||||||
|
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||||
|
context: FEDERATION_CONTEXT,
|
||||||
|
resource: 'tasks',
|
||||||
|
requestedLimit: 1,
|
||||||
|
nativeRbac: query,
|
||||||
|
});
|
||||||
|
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
|
||||||
|
expect(response).toEqual({
|
||||||
|
item: {
|
||||||
|
id: 'task-1',
|
||||||
|
title: 'Federated task',
|
||||||
|
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||||
|
_source: 'local',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||||
|
const { controller, scope, query } = makeController();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'unauthorized',
|
||||||
|
message: 'Federation context missing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||||
|
expect(query.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||||
|
const { controller, query } = makeController({
|
||||||
|
scopeResult: {
|
||||||
|
allowed: false,
|
||||||
|
deny: {
|
||||||
|
code: 'resource_excluded',
|
||||||
|
stage: 'resource_exclusion',
|
||||||
|
statusCode: 403,
|
||||||
|
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||||
|
grantId: 'grant-1',
|
||||||
|
peerId: 'peer-1',
|
||||||
|
subjectUserId: 'user-1',
|
||||||
|
resource: 'credentials',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'scope_violation',
|
||||||
|
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
expect(query.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
|
||||||
|
const { controller } = makeController({ queryResult: { status: 'not_found' } });
|
||||||
|
|
||||||
|
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: { error: { code: 'not_found' } },
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
|
||||||
|
const { controller } = makeController({
|
||||||
|
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'scope_violation',
|
||||||
|
message: 'Task is outside the federated scope',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when the query layer denies an unsupported resource', async () => {
|
||||||
|
const unsupportedFilter: FederationScopeQueryFilter = {
|
||||||
|
...TASK_FILTER,
|
||||||
|
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||||
|
};
|
||||||
|
const { controller } = makeController({
|
||||||
|
scopeResult: allowedScope(unsupportedFilter),
|
||||||
|
queryResult: {
|
||||||
|
status: 'denied',
|
||||||
|
reason: 'Unsupported federation get resource: unknown-resource',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 'scope_violation',
|
||||||
|
message: 'Unsupported federation get resource: unknown-resource',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty ids before evaluating scope', async () => {
|
||||||
|
const { controller, scope, query } = makeController();
|
||||||
|
|
||||||
|
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
|
||||||
|
response: { error: { code: 'invalid_request' } },
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||||
|
expect(query.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Federation get query layer (FED-M3-06).
|
||||||
|
*
|
||||||
|
* Read-only DB adapter used by GetController after FederationAuthGuard and
|
||||||
|
* FederationScopeService have established the subject user, allowed resource,
|
||||||
|
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
||||||
|
* deferred to M4.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
insights,
|
||||||
|
or,
|
||||||
|
missionTasks,
|
||||||
|
missions,
|
||||||
|
preferences,
|
||||||
|
projects,
|
||||||
|
tasks,
|
||||||
|
teamMembers,
|
||||||
|
type Db,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
import { DB } from '../../../database/database.module.js';
|
||||||
|
import type {
|
||||||
|
FederationNativeRbacEvaluator,
|
||||||
|
FederationNativeRbacRequest,
|
||||||
|
FederationNativeRbacResult,
|
||||||
|
FederationScopeQueryFilter,
|
||||||
|
} from '../scope.service.js';
|
||||||
|
|
||||||
|
export interface FederationGetQueryRequest {
|
||||||
|
readonly filter: FederationScopeQueryFilter;
|
||||||
|
readonly id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
|
||||||
|
readonly status: 'found';
|
||||||
|
readonly item: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FederationGetQueryNotFoundResult {
|
||||||
|
readonly status: 'not_found';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FederationGetQueryDeniedResult {
|
||||||
|
readonly status: 'denied';
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
|
||||||
|
| FederationGetQueryFoundResult<T>
|
||||||
|
| FederationGetQueryNotFoundResult
|
||||||
|
| FederationGetQueryDeniedResult;
|
||||||
|
|
||||||
|
type RowObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
function firstRow<T>(rows: T[]): T | undefined {
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowBelongsToAccessibleProjectOrMission(
|
||||||
|
row: { projectId?: string | null; missionId?: string | null },
|
||||||
|
projectIds: readonly string[],
|
||||||
|
missionIds: readonly string[],
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
|
||||||
|
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
async evaluateReadAccess(
|
||||||
|
request: FederationNativeRbacRequest,
|
||||||
|
): Promise<FederationNativeRbacResult> {
|
||||||
|
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `${request.resource} federation get access is not implemented in M3`,
|
||||||
|
details: { resource: request.resource },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.resource === 'memory') {
|
||||||
|
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
||||||
|
return { allowed: true, access: { includePersonal: true, teamIds } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T extends RowObject = RowObject>(
|
||||||
|
request: FederationGetQueryRequest,
|
||||||
|
): Promise<FederationGetQueryResult<T>> {
|
||||||
|
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getByResource(
|
||||||
|
filter: FederationScopeQueryFilter,
|
||||||
|
id: string,
|
||||||
|
): Promise<FederationGetQueryResult> {
|
||||||
|
switch (filter.resource) {
|
||||||
|
case 'tasks':
|
||||||
|
return this.getTask(filter, id);
|
||||||
|
case 'notes':
|
||||||
|
return this.getNote(filter, id);
|
||||||
|
case 'memory':
|
||||||
|
return this.getMemory(filter, id);
|
||||||
|
case 'credentials':
|
||||||
|
case 'api_keys':
|
||||||
|
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
status: 'denied',
|
||||||
|
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ teamId: teamMembers.teamId })
|
||||||
|
.from(teamMembers)
|
||||||
|
.where(eq(teamMembers.userId, subjectUserId));
|
||||||
|
|
||||||
|
return rows.map((row) => row.teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
||||||
|
const clauses = [];
|
||||||
|
if (filter.includePersonal) {
|
||||||
|
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
||||||
|
}
|
||||||
|
if (filter.teamIds.length > 0) {
|
||||||
|
// Project team ownership follows TeamsService.canAccessProject: team-owned
|
||||||
|
// rows are authorized through projects.teamId, while ownerId remains the
|
||||||
|
// user who created/bootstrapped the project.
|
||||||
|
clauses.push(
|
||||||
|
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clauses.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
||||||
|
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
||||||
|
if (projectIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ id: missions.id })
|
||||||
|
.from(missions)
|
||||||
|
.where(inArray(missions.projectId, [...projectIds]));
|
||||||
|
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTask(
|
||||||
|
filter: FederationScopeQueryFilter,
|
||||||
|
id: string,
|
||||||
|
): Promise<FederationGetQueryResult> {
|
||||||
|
const row = firstRow(
|
||||||
|
await this.db
|
||||||
|
.select({
|
||||||
|
id: tasks.id,
|
||||||
|
title: tasks.title,
|
||||||
|
description: tasks.description,
|
||||||
|
status: tasks.status,
|
||||||
|
priority: tasks.priority,
|
||||||
|
projectId: tasks.projectId,
|
||||||
|
missionId: tasks.missionId,
|
||||||
|
assignee: tasks.assignee,
|
||||||
|
tags: tasks.tags,
|
||||||
|
dueDate: tasks.dueDate,
|
||||||
|
metadata: tasks.metadata,
|
||||||
|
createdAt: tasks.createdAt,
|
||||||
|
updatedAt: tasks.updatedAt,
|
||||||
|
})
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, id))
|
||||||
|
.limit(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return { status: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||||
|
const missionIds = await this.listMissionIds(projectIds);
|
||||||
|
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
|
||||||
|
return { status: 'denied', reason: 'Task is outside the federated scope' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'found', item: row as RowObject };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getNote(
|
||||||
|
filter: FederationScopeQueryFilter,
|
||||||
|
id: string,
|
||||||
|
): Promise<FederationGetQueryResult> {
|
||||||
|
const row = firstRow(
|
||||||
|
await this.db
|
||||||
|
.select({
|
||||||
|
id: missionTasks.id,
|
||||||
|
missionId: missionTasks.missionId,
|
||||||
|
taskId: missionTasks.taskId,
|
||||||
|
userId: missionTasks.userId,
|
||||||
|
status: missionTasks.status,
|
||||||
|
content: missionTasks.notes,
|
||||||
|
createdAt: missionTasks.createdAt,
|
||||||
|
updatedAt: missionTasks.updatedAt,
|
||||||
|
})
|
||||||
|
.from(missionTasks)
|
||||||
|
.where(eq(missionTasks.id, id))
|
||||||
|
.limit(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row || row.content === null || row.content === '') {
|
||||||
|
return { status: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||||
|
const missionIds = await this.listMissionIds(projectIds);
|
||||||
|
|
||||||
|
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||||
|
// Scope-visible missions must intersect with subject ownership; team scope
|
||||||
|
// narrows mission IDs but never widens note reads to another user's rows.
|
||||||
|
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
|
||||||
|
return { status: 'denied', reason: 'Note is outside the federated scope' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = { ...row } as RowObject;
|
||||||
|
delete item['userId'];
|
||||||
|
return { status: 'found', item };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMemory(
|
||||||
|
filter: FederationScopeQueryFilter,
|
||||||
|
id: string,
|
||||||
|
): Promise<FederationGetQueryResult> {
|
||||||
|
const [insightRow, preferenceRow] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.select({
|
||||||
|
id: insights.id,
|
||||||
|
userId: insights.userId,
|
||||||
|
kind: insights.source,
|
||||||
|
content: insights.content,
|
||||||
|
category: insights.category,
|
||||||
|
relevanceScore: insights.relevanceScore,
|
||||||
|
metadata: insights.metadata,
|
||||||
|
createdAt: insights.createdAt,
|
||||||
|
updatedAt: insights.updatedAt,
|
||||||
|
})
|
||||||
|
.from(insights)
|
||||||
|
.where(eq(insights.id, id))
|
||||||
|
.limit(1)
|
||||||
|
.then(firstRow),
|
||||||
|
this.db
|
||||||
|
.select({
|
||||||
|
id: preferences.id,
|
||||||
|
userId: preferences.userId,
|
||||||
|
kind: preferences.category,
|
||||||
|
key: preferences.key,
|
||||||
|
value: preferences.value,
|
||||||
|
source: preferences.source,
|
||||||
|
mutable: preferences.mutable,
|
||||||
|
createdAt: preferences.createdAt,
|
||||||
|
updatedAt: preferences.updatedAt,
|
||||||
|
})
|
||||||
|
.from(preferences)
|
||||||
|
.where(eq(preferences.id, id))
|
||||||
|
.limit(1)
|
||||||
|
.then(firstRow),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const candidates = [insightRow, preferenceRow].filter(
|
||||||
|
(row): row is NonNullable<typeof row> => row !== undefined,
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return { status: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter.includePersonal) {
|
||||||
|
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
|
||||||
|
if (!accessible) {
|
||||||
|
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = { ...accessible } as RowObject;
|
||||||
|
delete item['userId'];
|
||||||
|
return { status: 'found', item };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Federation get verb (FED-M3-06).
|
||||||
|
*
|
||||||
|
* POST /api/federation/v1/get/:resource/:id
|
||||||
|
*
|
||||||
|
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||||
|
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||||
|
* the read-only query layer fetches one local row and tags it with `_source`.
|
||||||
|
* Read audit-log writes are deferred to M4; this controller does not persist
|
||||||
|
* request or response bodies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import {
|
||||||
|
FederationInvalidRequestError,
|
||||||
|
FederationNotFoundError,
|
||||||
|
FederationScopeViolationError,
|
||||||
|
FederationUnauthorizedError,
|
||||||
|
SOURCE_LOCAL,
|
||||||
|
type FederationGetResponse,
|
||||||
|
type SourceTag,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||||
|
import '../federation-context.js';
|
||||||
|
import { FederationScopeService } from '../scope.service.js';
|
||||||
|
import { FederationGetQueryService } from './get-query.service.js';
|
||||||
|
|
||||||
|
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||||
|
|
||||||
|
function scopeDenyToHttpException(deny: {
|
||||||
|
readonly statusCode: 400 | 403;
|
||||||
|
readonly message: string;
|
||||||
|
}): HttpException {
|
||||||
|
const ErrorClass =
|
||||||
|
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
|
||||||
|
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api/federation/v1/get')
|
||||||
|
@UseGuards(FederationAuthGuard)
|
||||||
|
export class GetController {
|
||||||
|
constructor(
|
||||||
|
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||||
|
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post(':resource/:id')
|
||||||
|
async get(
|
||||||
|
@Param('resource') resource: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
): Promise<FederationGetResponse<FederatedRow>> {
|
||||||
|
if (!request.federationContext) {
|
||||||
|
throw new HttpException(
|
||||||
|
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (id.trim().length === 0) {
|
||||||
|
throw new HttpException(
|
||||||
|
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeResult = await this.scope.evaluateAccess({
|
||||||
|
context: request.federationContext,
|
||||||
|
resource,
|
||||||
|
requestedLimit: 1,
|
||||||
|
nativeRbac: this.query,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scopeResult.allowed) {
|
||||||
|
throw scopeDenyToHttpException(scopeResult.deny);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.query.get({ filter: scopeResult.filter, id });
|
||||||
|
if (result.status === 'not_found') {
|
||||||
|
throw new HttpException(
|
||||||
|
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.status === 'denied') {
|
||||||
|
throw new HttpException(
|
||||||
|
new FederationScopeViolationError(result.reason, {
|
||||||
|
resource,
|
||||||
|
id,
|
||||||
|
grantId: request.federationContext.grantId,
|
||||||
|
peerId: request.federationContext.peerId,
|
||||||
|
subjectUserId: request.federationContext.subjectUserId,
|
||||||
|
}).toEnvelope(),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item: { ...result.item, _source: SOURCE_LOCAL } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import { SessionGCService } from './session-gc.service.js';
|
import { SessionGCService } from './session-gc.service.js';
|
||||||
import { REDIS } from './gc.tokens.js';
|
import { REDIS } from './gc.tokens.js';
|
||||||
|
|
||||||
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: GC_QUEUE_HANDLE,
|
provide: GC_QUEUE_HANDLE,
|
||||||
useFactory: (): QueueHandle => {
|
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||||
|
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
||||||
|
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
||||||
|
if (config?.queue?.type === 'local') return null;
|
||||||
return createQueue();
|
return createQueue();
|
||||||
},
|
},
|
||||||
|
inject: [MOSAIC_CONFIG],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: REDIS,
|
provide: REDIS,
|
||||||
useFactory: (handle: QueueHandle) => handle.redis,
|
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||||
inject: [GC_QUEUE_HANDLE],
|
inject: [GC_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
SessionGCService,
|
SessionGCService,
|
||||||
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
exports: [SessionGCService],
|
exports: [SessionGCService],
|
||||||
})
|
})
|
||||||
export class GCModule implements OnApplicationShutdown {
|
export class GCModule implements OnApplicationShutdown {
|
||||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
constructor(
|
||||||
|
@Optional()
|
||||||
|
@Inject(GC_QUEUE_HANDLE)
|
||||||
|
private readonly handle: QueueHandle | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await this.handle.close().catch(() => {});
|
await this.handle?.close().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
|
|||||||
).resolves.toEqual({ allowed: true });
|
).resolves.toEqual({ allowed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
|
||||||
|
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
|
||||||
|
|
||||||
|
const result = await localService.collect('local-session');
|
||||||
|
|
||||||
|
expect(result.sessionId).toBe('local-session');
|
||||||
|
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||||
|
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
||||||
|
'local-session',
|
||||||
|
expect.any(Date),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('collect() returns sessionId in result', async () => {
|
it('collect() returns sessionId in result', async () => {
|
||||||
const result = await service.collect('test-session-id');
|
const result = await service.collect('test-session-id');
|
||||||
expect(result.sessionId).toBe('test-session-id');
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import type { QueueHandle } from '@mosaicstack/queue';
|
import type { QueueHandle } from '@mosaicstack/queue';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
||||||
|
@Optional()
|
||||||
|
@Inject(REDIS)
|
||||||
|
private readonly redis: QueueHandle['redis'] | null,
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -29,8 +32,10 @@ export class SessionGCService {
|
|||||||
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
|
||||||
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
* KEYS is avoided because it blocks the Valkey event loop for the full scan
|
||||||
* duration, which can cause latency spikes under production key volumes.
|
* duration, which can cause latency spikes under production key volumes.
|
||||||
|
* Returns an empty population on the Local tier where Redis is disabled.
|
||||||
*/
|
*/
|
||||||
private async scanKeys(pattern: string): Promise<string[]> {
|
private async scanKeys(pattern: string): Promise<string[]> {
|
||||||
|
if (!this.redis) return [];
|
||||||
const collected: string[] = [];
|
const collected: string[] = [];
|
||||||
let cursor = '0';
|
let cursor = '0';
|
||||||
do {
|
do {
|
||||||
@@ -47,13 +52,15 @@ export class SessionGCService {
|
|||||||
async collect(sessionId: string): Promise<GCResult> {
|
async collect(sessionId: string): Promise<GCResult> {
|
||||||
const result: GCResult = { sessionId, cleaned: {} };
|
const result: GCResult = { sessionId, cleaned: {} };
|
||||||
|
|
||||||
// 1. Valkey: delete all session-scoped keys
|
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
||||||
|
if (this.redis) {
|
||||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||||
const valkeyKeys = await this.scanKeys(pattern);
|
const valkeyKeys = await this.scanKeys(pattern);
|
||||||
if (valkeyKeys.length > 0) {
|
if (valkeyKeys.length > 0) {
|
||||||
await this.redis.del(...valkeyKeys);
|
await this.redis.del(...valkeyKeys);
|
||||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. PG: demote hot-tier agent logs for this session only.
|
// 2. PG: demote hot-tier agent logs for this session only.
|
||||||
const cutoff = new Date();
|
const cutoff = new Date();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
|
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
|
||||||
|
if (!this.queueService.isEnabled()) {
|
||||||
|
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||||
|
|
||||||
@@ -39,7 +45,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||||
await this.summarization.runSummarization();
|
await this.summarization.runSummarization();
|
||||||
});
|
});
|
||||||
this.registeredWorkers.push(summarizationWorker);
|
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||||
|
|
||||||
// M6-005: Tier management repeatable job
|
// M6-005: Tier management repeatable job
|
||||||
await this.queueService.addRepeatableJob(
|
await this.queueService.addRepeatableJob(
|
||||||
@@ -51,7 +57,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||||
await this.summarization.runTierManagement();
|
await this.summarization.runTierManagement();
|
||||||
});
|
});
|
||||||
this.registeredWorkers.push(tierWorker);
|
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||||
|
|
||||||
// Retire any repeatable global GC schedule created by older deployments.
|
// Retire any repeatable global GC schedule created by older deployments.
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import * as nodeOs from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import * as nodeUrl from 'node:url';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import type * as MosaicStorage from '@mosaicstack/storage';
|
||||||
|
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||||
|
|
||||||
|
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||||
|
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||||
|
return { ...process.env };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in snapshot)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(snapshot)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||||
|
const relativePath = relative(tempRoot, path);
|
||||||
|
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||||
|
expectPathUnderTempRoot(path, tempRoot);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BootstrapPreflightResult {
|
||||||
|
capturedConfig: MosaicConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBootstrapPreflight(
|
||||||
|
anchoredConfigContents: string,
|
||||||
|
ambientConfigContents: string,
|
||||||
|
): Promise<BootstrapPreflightResult> {
|
||||||
|
const originalEnv = snapshotProcessEnv();
|
||||||
|
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
|
||||||
|
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let exitSpy: MockInstance<typeof process.exit> | undefined;
|
||||||
|
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||||
|
let capturedConfig: MosaicConfig | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||||
|
const homePath = join(tempRoot, 'home');
|
||||||
|
const cwdPath = join(tempRoot, 'ambient', 'cwd');
|
||||||
|
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||||
|
|
||||||
|
await mkdir(anchor, { recursive: true });
|
||||||
|
await mkdir(cwdPath, { recursive: true });
|
||||||
|
|
||||||
|
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
|
||||||
|
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
|
||||||
|
|
||||||
|
process.env['HOME'] = homePath;
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
|
||||||
|
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['VALKEY_URL'];
|
||||||
|
|
||||||
|
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||||
|
const exitMock = vi.fn<typeof process.exit>();
|
||||||
|
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||||
|
vi.doMock('node:url', () => ({
|
||||||
|
...nodeUrl,
|
||||||
|
fileURLToPath: (url: string | URL): string => {
|
||||||
|
const actualPath = nodeUrl.fileURLToPath(url);
|
||||||
|
if (
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||||
|
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||||
|
) {
|
||||||
|
return join(anchor, 'env.ts');
|
||||||
|
}
|
||||||
|
return actualPath;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||||
|
vi.doMock('./tracing.js', () => ({}));
|
||||||
|
|
||||||
|
const preflightSentinel = new Error('preflight-capture-sentinel');
|
||||||
|
vi.doMock('@mosaicstack/storage', async () => {
|
||||||
|
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
|
||||||
|
capturedConfig = config;
|
||||||
|
throw preflightSentinel;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await import('./main.js');
|
||||||
|
await vi.waitFor((): void => {
|
||||||
|
expect(exitSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { capturedConfig };
|
||||||
|
} finally {
|
||||||
|
cwdSpy?.mockRestore();
|
||||||
|
exitSpy?.mockRestore();
|
||||||
|
consoleInfoSpy?.mockRestore();
|
||||||
|
vi.doUnmock('@mosaicstack/storage');
|
||||||
|
vi.doUnmock('./tracing.js');
|
||||||
|
vi.doUnmock('node:url');
|
||||||
|
vi.doUnmock('node:os');
|
||||||
|
vi.resetModules();
|
||||||
|
restoreProcessEnv(originalEnv);
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('main bootstrap preflight config anchoring', (): void => {
|
||||||
|
it(
|
||||||
|
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
|
||||||
|
async (): Promise<void> => {
|
||||||
|
const anchoredConfig = JSON.stringify({
|
||||||
|
tier: 'local',
|
||||||
|
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||||
|
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
const ambientConfig = JSON.stringify({
|
||||||
|
tier: 'federated',
|
||||||
|
storage: {
|
||||||
|
type: 'postgres',
|
||||||
|
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||||
|
enableVector: true,
|
||||||
|
},
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
|
||||||
|
|
||||||
|
expect(capturedConfig?.tier).toBe('local');
|
||||||
|
expect(capturedConfig?.storage).not.toEqual(
|
||||||
|
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { config } from 'dotenv';
|
import './env.js';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { resolve, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
// Load .env from daemon config dir (global install / daemon mode).
|
|
||||||
// Loaded first so monorepo .env can override for local dev.
|
|
||||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
|
||||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
|
||||||
|
|
||||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
|
||||||
config({ path: resolve(process.cwd(), '../../.env') });
|
|
||||||
config(); // Also load apps/gateway/.env if present (overrides)
|
|
||||||
|
|
||||||
import './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -26,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
|||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
@@ -37,7 +25,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
// Pre-flight: assert all external services required by the configured tier
|
// Pre-flight: assert all external services required by the configured tier
|
||||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||||
// immediately with actionable remediation hints.
|
// immediately with actionable remediation hints.
|
||||||
const mosaicConfig = loadConfig();
|
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
||||||
try {
|
try {
|
||||||
await detectAndAssertTier(mosaicConfig);
|
await detectAndAssertTier(mosaicConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { McpClientService } from './mcp-client.service.js';
|
||||||
|
|
||||||
|
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
|
||||||
|
|
||||||
|
describe('McpClientService — failed connect error sanitization', () => {
|
||||||
|
const originalMcpServers = process.env['MCP_SERVERS'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env['MCP_SERVERS'] = JSON.stringify([
|
||||||
|
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (originalMcpServers === undefined) {
|
||||||
|
delete process.env['MCP_SERVERS'];
|
||||||
|
} else {
|
||||||
|
process.env['MCP_SERVERS'] = originalMcpServers;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
|
||||||
|
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
|
||||||
|
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const service = new McpClientService();
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
const statuses = service.getServerStatuses();
|
||||||
|
expect(statuses).toHaveLength(1);
|
||||||
|
expect(statuses[0]?.connected).toBe(false);
|
||||||
|
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
|
||||||
|
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
|
||||||
|
|
||||||
|
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
|
||||||
|
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
|
||||||
|
);
|
||||||
|
expect(loggedRawMarker).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
serverEntry.error = message;
|
serverEntry.error = 'Connection failed (see server logs).';
|
||||||
serverEntry.connected = false;
|
serverEntry.connected = false;
|
||||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { SystemOverrideService } from './system-override.service.js';
|
||||||
|
|
||||||
|
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
|
||||||
|
|
||||||
|
describe('SystemOverrideService local tier', () => {
|
||||||
|
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
|
||||||
|
const service = new SystemOverrideService(localConfig);
|
||||||
|
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
|
||||||
|
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
|
||||||
|
|
||||||
|
await service.set('shared-session', 'first override', firstScope);
|
||||||
|
await service.set('shared-session', 'second override', secondScope);
|
||||||
|
|
||||||
|
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
|
||||||
|
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||||
|
|
||||||
|
await service.clear('shared-session', firstScope);
|
||||||
|
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
|
||||||
|
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
|
|
||||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||||
@@ -15,16 +17,45 @@ interface OverrideFragment {
|
|||||||
addedAt: number;
|
addedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
interface LocalOverrideEntry {
|
||||||
export class SystemOverrideService {
|
condensed: string;
|
||||||
private readonly logger = new Logger(SystemOverrideService.name);
|
fragments: OverrideFragment[];
|
||||||
private readonly handle: QueueHandle;
|
}
|
||||||
|
|
||||||
constructor() {
|
@Injectable()
|
||||||
this.handle = createQueue();
|
export class SystemOverrideService implements OnApplicationShutdown {
|
||||||
|
private readonly logger = new Logger(SystemOverrideService.name);
|
||||||
|
private readonly handle: QueueHandle | null;
|
||||||
|
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
||||||
|
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Optional()
|
||||||
|
@Inject(MOSAIC_CONFIG)
|
||||||
|
private readonly mosaicConfig: MosaicConfig | null,
|
||||||
|
) {
|
||||||
|
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onApplicationShutdown(): Promise<void> {
|
||||||
|
await this.handle?.close().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) {
|
||||||
|
const key = scopedSessionId(sessionId, scope);
|
||||||
|
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
|
||||||
|
entry.fragments.push({ text: override, addedAt: Date.now() });
|
||||||
|
entry.condensed = await this.condenseOverrides(
|
||||||
|
entry.fragments.map((fragment) => fragment.text),
|
||||||
|
);
|
||||||
|
this.localStore.set(key, entry);
|
||||||
|
this.logger.debug(
|
||||||
|
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Load existing fragments
|
// Load existing fragments
|
||||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||||
const fragments: OverrideFragment[] = existing
|
const fragments: OverrideFragment[] = existing
|
||||||
@@ -54,10 +85,14 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||||
|
if (!this.handle) {
|
||||||
|
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
||||||
|
}
|
||||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) return;
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
@@ -65,6 +100,11 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) {
|
||||||
|
this.localStore.delete(scopedSessionId(sessionId, scope));
|
||||||
|
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.handle.redis.del(
|
await this.handle.redis.del(
|
||||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { QueueService } from './queue.service.js';
|
||||||
|
|
||||||
|
const localConfig = {
|
||||||
|
queue: { type: 'local' },
|
||||||
|
} as MosaicConfig;
|
||||||
|
|
||||||
|
describe('QueueService local tier', () => {
|
||||||
|
it('disables BullMQ and treats queue operations as local no-ops', async () => {
|
||||||
|
const service = new QueueService(null, localConfig);
|
||||||
|
|
||||||
|
expect(service.isEnabled()).toBe(false);
|
||||||
|
expect(service.getQueue('mosaic-test')).toBeNull();
|
||||||
|
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
|
||||||
|
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
|
||||||
|
await expect(service.listJobs()).resolves.toEqual([]);
|
||||||
|
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'BullMQ is disabled on local tier.',
|
||||||
|
});
|
||||||
|
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'BullMQ is disabled on local tier.',
|
||||||
|
});
|
||||||
|
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
message: 'BullMQ is disabled on local tier.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||||
import type { LogService } from '@mosaicstack/log';
|
import type { LogService } from '@mosaicstack/log';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -108,22 +110,43 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly connection: ConnectionOptions;
|
private readonly connection: ConnectionOptions;
|
||||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||||
|
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
||||||
|
private readonly enabled: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(LOG_SERVICE)
|
@Inject(LOG_SERVICE)
|
||||||
private readonly logService: LogService | null,
|
private readonly logService: LogService | null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(MOSAIC_CONFIG)
|
||||||
|
private readonly mosaicConfig: MosaicConfig | null,
|
||||||
) {
|
) {
|
||||||
this.connection = getConnection();
|
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
||||||
|
this.connection = this.enabled
|
||||||
|
? getConnection()
|
||||||
|
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
|
if (this.enabled) {
|
||||||
this.logger.log('QueueService initialised (BullMQ)');
|
this.logger.log('QueueService initialised (BullMQ)');
|
||||||
|
} else {
|
||||||
|
this.logger.log(
|
||||||
|
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.enabled) {
|
||||||
await this.closeAll();
|
await this.closeAll();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Queue helpers
|
// Queue helpers
|
||||||
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a BullMQ Queue for the given queue name.
|
* Get or create a BullMQ Queue for the given queue name.
|
||||||
|
* Returns null on Local tier where BullMQ is disabled.
|
||||||
*/
|
*/
|
||||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
||||||
|
if (!this.enabled) return null;
|
||||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||||
if (!queue) {
|
if (!queue) {
|
||||||
queue = new Queue<T>(name, { connection: this.connection });
|
queue = new Queue<T>(name, { connection: this.connection });
|
||||||
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Add a BullMQ repeatable job (cron-style).
|
* Add a BullMQ repeatable job (cron-style).
|
||||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||||
|
* No-op on Local tier.
|
||||||
*/
|
*/
|
||||||
async addRepeatableJob<T extends MosaicJobData>(
|
async addRepeatableJob<T extends MosaicJobData>(
|
||||||
queueName: string,
|
queueName: string,
|
||||||
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
data: T,
|
data: T,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const queue = this.getQueue<T>(queueName);
|
if (!this.enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queue = this.getQueue<T>(queueName)!;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await (queue as Queue<any>).add(jobName, data, {
|
await (queue as Queue<any>).add(jobName, data, {
|
||||||
repeat: { pattern: cronExpression },
|
repeat: { pattern: cronExpression },
|
||||||
@@ -167,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* safe retirement of previously registered system-wide jobs.
|
* safe retirement of previously registered system-wide jobs.
|
||||||
*/
|
*/
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const queue = this.getQueue(queueName);
|
const queue = this.getQueue(queueName);
|
||||||
|
if (!queue) return 0;
|
||||||
const jobs = await queue.getRepeatableJobs();
|
const jobs = await queue.getRepeatableJobs();
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
||||||
@@ -182,8 +221,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Register a Worker for the given queue name with error handling and
|
* Register a Worker for the given queue name with error handling and
|
||||||
* exponential backoff.
|
* exponential backoff.
|
||||||
|
* Returns null on Local tier where BullMQ is disabled.
|
||||||
*/
|
*/
|
||||||
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
registerWorker<T extends MosaicJobData>(
|
||||||
|
queueName: string,
|
||||||
|
handler: JobHandler<T>,
|
||||||
|
): Worker<T> | null {
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const worker = new Worker<T>(
|
const worker = new Worker<T>(
|
||||||
queueName,
|
queueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@@ -240,8 +289,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return queue health statistics for all managed queues.
|
* Return queue health statistics for all managed queues.
|
||||||
|
* Returns an empty healthy result on Local tier.
|
||||||
*/
|
*/
|
||||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return { queues: {}, healthy: true };
|
||||||
|
}
|
||||||
const queues: QueueHealthStatus['queues'] = {};
|
const queues: QueueHealthStatus['queues'] = {};
|
||||||
let healthy = true;
|
let healthy = true;
|
||||||
|
|
||||||
@@ -272,8 +325,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* List jobs across all managed queues, optionally filtered by status.
|
* List jobs across all managed queues, optionally filtered by status.
|
||||||
* BullMQ jobs are fetched by state type from each queue.
|
* BullMQ jobs are fetched by state type from each queue.
|
||||||
|
* Returns empty array on Local tier.
|
||||||
*/
|
*/
|
||||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||||
|
if (!this.enabled) return [];
|
||||||
const jobs: JobDto[] = [];
|
const jobs: JobDto[] = [];
|
||||||
const states: JobStatus[] = status
|
const states: JobStatus[] = status
|
||||||
? [status]
|
? [status]
|
||||||
@@ -300,8 +355,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||||
* job IDs are not globally unique — they are scoped to their queue.
|
* job IDs are not globally unique — they are scoped to their queue.
|
||||||
|
* Returns an error on Local tier.
|
||||||
*/
|
*/
|
||||||
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||||
const sep = compositeId.lastIndexOf('__');
|
const sep = compositeId.lastIndexOf('__');
|
||||||
if (sep === -1) {
|
if (sep === -1) {
|
||||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||||
@@ -333,6 +390,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Pause a queue by name.
|
* Pause a queue by name.
|
||||||
*/
|
*/
|
||||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||||
const queue = this.queues.get(name);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.pause();
|
await queue.pause();
|
||||||
@@ -344,6 +402,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Resume a paused queue by name.
|
* Resume a paused queue by name.
|
||||||
*/
|
*/
|
||||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||||
const queue = this.queues.get(name);
|
const queue = this.queues.get(name);
|
||||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||||
await queue.resume();
|
await queue.resume();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
|
||||||
import { ReloadService } from './reload.service.js';
|
import { ReloadService } from './reload.service.js';
|
||||||
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
|
|
||||||
function createMockCommandRegistry() {
|
function createMockCommandRegistry() {
|
||||||
return {
|
return {
|
||||||
@@ -104,3 +107,79 @@ describe('ReloadService', () => {
|
|||||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||||
|
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
|
||||||
|
const registry = {
|
||||||
|
getManifest: vi.fn().mockReturnValue({
|
||||||
|
version: 1,
|
||||||
|
commands: [
|
||||||
|
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const reloadService = new ReloadService(registry as never);
|
||||||
|
|
||||||
|
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
|
||||||
|
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
|
||||||
|
|
||||||
|
reloadService.registerPlugin('unload-fails', {
|
||||||
|
pluginName: 'unload-fails',
|
||||||
|
onLoad: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||||
|
});
|
||||||
|
reloadService.registerPlugin('load-fails', {
|
||||||
|
pluginName: 'load-fails',
|
||||||
|
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
|
||||||
|
onUnload: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
const broadcastReload = vi.fn();
|
||||||
|
const mockChatGateway = { broadcastReload };
|
||||||
|
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
|
||||||
|
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
|
||||||
|
const mockSessionGC = { sweepOrphans: vi.fn() };
|
||||||
|
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
||||||
|
|
||||||
|
const executor = new CommandExecutorService(
|
||||||
|
registry as never,
|
||||||
|
mockAgentService as never,
|
||||||
|
mockSystemOverride as never,
|
||||||
|
mockSessionGC as never,
|
||||||
|
null,
|
||||||
|
mockBrain as never,
|
||||||
|
reloadService,
|
||||||
|
mockChatGateway as never,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||||
|
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('unload-fails: unload failed (internal error)');
|
||||||
|
expect(result.message).toContain('load-fails: load failed (internal error)');
|
||||||
|
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||||
|
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||||
|
|
||||||
|
expect(broadcastReload).toHaveBeenCalledOnce();
|
||||||
|
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
|
||||||
|
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
|
||||||
|
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
|
||||||
|
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||||
|
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||||
|
|
||||||
|
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
|
||||||
|
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||||
|
);
|
||||||
|
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
|
||||||
|
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
|
||||||
|
);
|
||||||
|
expect(loggedUnloadMarker).toBe(true);
|
||||||
|
expect(loggedLoadMarker).toBe(true);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
await plugin.onUnload();
|
await plugin.onUnload();
|
||||||
reloaded.push(name);
|
reloaded.push(name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`${name}: unload failed — ${err}`);
|
this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
|
||||||
|
errors.push(`${name}: unload failed (internal error)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
|||||||
try {
|
try {
|
||||||
await plugin.onLoad();
|
await plugin.onLoad();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`${name}: load failed — ${err}`);
|
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
|
||||||
|
errors.push(`${name}: load failed (internal error)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import {
|
||||||
|
type CanActivate,
|
||||||
|
type ExecutionContext,
|
||||||
|
type INestApplication,
|
||||||
|
ValidationPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
|
import { WorkspaceController } from './workspace.controller.js';
|
||||||
|
|
||||||
|
const bootstrapMock = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
projectId: 'project-1',
|
||||||
|
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authGuard: CanActivate = {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||||
|
requestContext.user = { id: 'user-1' };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('POST /api/workspaces repoUrl validation', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [WorkspaceController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ProjectBootstrapService,
|
||||||
|
useValue: { bootstrap: bootstrapMock },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(authGuard)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bootstrapMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a leading-dash value', '--upload-pack=sh -c id'],
|
||||||
|
['an ext remote helper', 'ext::sh -c id'],
|
||||||
|
['a file URL', 'file:///tmp/repository'],
|
||||||
|
['an unparseable value', 'not a url'],
|
||||||
|
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||||
|
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||||
|
['a hostless git URL', 'git:///tmp/repository'],
|
||||||
|
])('returns 400 for %s', async (_description, repoUrl) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/workspaces')
|
||||||
|
.send({ name: 'Example', repoUrl })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(bootstrapMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
|
||||||
|
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
|
||||||
|
])('accepts %s', async (_description, repoUrl) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/workspaces')
|
||||||
|
.send({ name: 'Example', repoUrl })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(bootstrapMock).toHaveBeenCalledWith({
|
||||||
|
name: 'Example',
|
||||||
|
description: undefined,
|
||||||
|
userId: 'user-1',
|
||||||
|
teamId: undefined,
|
||||||
|
repoUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
import {
|
||||||
|
ProjectBootstrapService,
|
||||||
|
type BootstrapProjectResult,
|
||||||
|
} from './project-bootstrap.service.js';
|
||||||
|
import { CreateWorkspaceDto } from './workspace.dto.js';
|
||||||
|
|
||||||
@Controller('api/workspaces')
|
@Controller('api/workspaces')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -11,20 +15,14 @@ export class WorkspaceController {
|
|||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body()
|
@Body() dto: CreateWorkspaceDto,
|
||||||
body: {
|
): Promise<BootstrapProjectResult> {
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
teamId?: string;
|
|
||||||
repoUrl?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return this.bootstrap.bootstrap({
|
return this.bootstrap.bootstrap({
|
||||||
name: body.name,
|
name: dto.name,
|
||||||
description: body.description,
|
description: dto.description,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: body.teamId,
|
teamId: dto.teamId,
|
||||||
repoUrl: body.repoUrl,
|
repoUrl: dto.repoUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateWorkspaceDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10_000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
teamId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^(?:https|git):\/\//i, {
|
||||||
|
message: 'repoUrl must be a valid https:// or git:// URL',
|
||||||
|
})
|
||||||
|
@IsUrl(
|
||||||
|
{
|
||||||
|
protocols: ['https', 'git'],
|
||||||
|
require_host: true,
|
||||||
|
require_protocol: true,
|
||||||
|
require_tld: false,
|
||||||
|
require_valid_protocol: true,
|
||||||
|
},
|
||||||
|
{ message: 'repoUrl must be a valid https:// or git:// URL' },
|
||||||
|
)
|
||||||
|
repoUrl?: string;
|
||||||
|
}
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { WorkspaceService } from './workspace.service.js';
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
|
||||||
|
type ExecFileMock = (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options: { cwd: string },
|
||||||
|
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
const { execFileMock } = vi.hoisted(() => ({
|
||||||
|
execFileMock: vi.fn<ExecFileMock>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
execFile: execFileMock,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('WorkspaceService', () => {
|
describe('WorkspaceService', () => {
|
||||||
let service: WorkspaceService;
|
let service: WorkspaceService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
execFileMock.mockReset();
|
||||||
|
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||||
|
callback(null, '', '');
|
||||||
|
});
|
||||||
service = new WorkspaceService();
|
service = new WorkspaceService();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,4 +98,69 @@ describe('WorkspaceService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
const project = {
|
||||||
|
id: 'project-1',
|
||||||
|
ownerType: 'user',
|
||||||
|
userId: 'user-1',
|
||||||
|
teamId: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let originalRoot: string | undefined;
|
||||||
|
let temporaryRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
originalRoot = process.env['MOSAIC_ROOT'];
|
||||||
|
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
|
||||||
|
process.env['MOSAIC_ROOT'] = temporaryRoot;
|
||||||
|
service = new WorkspaceService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalRoot === undefined) {
|
||||||
|
delete process.env['MOSAIC_ROOT'];
|
||||||
|
} else {
|
||||||
|
process.env['MOSAIC_ROOT'] = originalRoot;
|
||||||
|
}
|
||||||
|
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a leading-dash URL', '--upload-pack=sh -c id'],
|
||||||
|
['an ext remote helper', 'ext::sh -c id'],
|
||||||
|
['a file URL', 'file:///tmp/repository'],
|
||||||
|
['an unparseable value', 'not a url'],
|
||||||
|
['an SSH shorthand', '[email protected]:acme/repository.git'],
|
||||||
|
['a scheme without //', 'https:example.com/acme/repository.git'],
|
||||||
|
['a hostless git URL', 'git:///tmp/repository'],
|
||||||
|
])('rejects %s before invoking git', async (_description, repoUrl) => {
|
||||||
|
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(execFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['an HTTPS URL', 'https://example.com/acme/repository.git'],
|
||||||
|
['a git protocol URL', 'git://example.com/acme/repository.git'],
|
||||||
|
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
|
||||||
|
const workspacePath = await service.create(project, repoUrl);
|
||||||
|
|
||||||
|
expect(execFileMock).toHaveBeenCalledOnce();
|
||||||
|
expect(execFileMock).toHaveBeenCalledWith(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.ext.allow=never',
|
||||||
|
'-c',
|
||||||
|
'protocol.file.allow=never',
|
||||||
|
'clone',
|
||||||
|
'--',
|
||||||
|
repoUrl,
|
||||||
|
'.',
|
||||||
|
],
|
||||||
|
{ cwd: workspacePath },
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
||||||
|
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
||||||
|
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
||||||
|
|
||||||
|
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
||||||
|
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(repoUrl);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
||||||
|
throw new BadRequestException(repositoryUrlError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceProject {
|
export interface WorkspaceProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,14 +59,32 @@ export class WorkspaceService {
|
|||||||
* If repoUrl is provided, clone instead of init.
|
* If repoUrl is provided, clone instead of init.
|
||||||
*/
|
*/
|
||||||
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
||||||
|
if (repoUrl !== undefined) {
|
||||||
|
assertAllowedRepositoryUrl(repoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const workspacePath = this.resolvePath(project);
|
const workspacePath = this.resolvePath(project);
|
||||||
|
|
||||||
// Create directory
|
// Create directory
|
||||||
await fs.mkdir(workspacePath, { recursive: true });
|
await fs.mkdir(workspacePath, { recursive: true });
|
||||||
|
|
||||||
if (repoUrl) {
|
if (repoUrl !== undefined) {
|
||||||
// Clone existing repo
|
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
||||||
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
// disabled and terminates option parsing before positional arguments.
|
||||||
|
await execFileAsync(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.ext.allow=never',
|
||||||
|
'-c',
|
||||||
|
'protocol.file.allow=never',
|
||||||
|
'clone',
|
||||||
|
'--',
|
||||||
|
repoUrl,
|
||||||
|
'.',
|
||||||
|
],
|
||||||
|
{ cwd: workspacePath },
|
||||||
|
);
|
||||||
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
||||||
} else {
|
} else {
|
||||||
// Init new git repo
|
// Init new git repo
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mosaic</title>
|
||||||
|
<meta name="description" content="Mosaic Stack Dashboard" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
||||||
|
/>
|
||||||
|
<script>
|
||||||
|
// set data-theme before first paint so the stored theme never flashes
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||||||
|
} catch (error) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build-web.mjs",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
|
"build:vite": "vite build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"dev:vite": "vite",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
@@ -13,12 +15,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
|
"@mosaicstack/types": "workspace:^",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -28,9 +32,11 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vite": "^8.2.1",
|
||||||
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,41 +3,56 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
import { getSsoProvider } from '@/lib/sso-providers';
|
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||||
|
|
||||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||||
const params = useParams<{ provider: string }>();
|
const params = useParams<{ provider: string }>();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||||
const provider = getSsoProvider(providerId);
|
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||||
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
const [providerName, setProviderName] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentProvider = provider;
|
let cancelled = false;
|
||||||
|
|
||||||
if (!currentProvider) {
|
async function redirectToProvider(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||||
|
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||||
|
if (!provider) {
|
||||||
setError('Unknown SSO provider.');
|
setError('Unknown SSO provider.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentProvider.enabled) {
|
setProviderName(provider.name);
|
||||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
if (!provider.configured) {
|
||||||
|
setError(`${provider.name} is not enabled in this deployment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (provider.loginMode !== 'oidc') {
|
||||||
|
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeProvider = currentProvider;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function redirectToProvider(): Promise<void> {
|
|
||||||
const result = await signIn.oauth2({
|
const result = await signIn.oauth2({
|
||||||
providerId: activeProvider.id,
|
providerId: provider.id,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cancelled && result?.error) {
|
if (!cancelled && result?.error) {
|
||||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||||
|
}
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,19 +61,22 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [callbackURL, provider]);
|
}, [providerId, requestedCallbackURL]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
<p className="mt-2 text-sm text-text-secondary">
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
{provider
|
{providerName
|
||||||
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
? `Redirecting you to ${providerName}...`
|
||||||
: 'Preparing your sign-in request...'}
|
: 'Preparing your sign-in request...'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||||
|
>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
describe('api', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
api<{ ok: boolean }>('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: 'Mosaic' },
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/projects',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name: 'Mosaic' }),
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws the gateway JSON error with its statusCode', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
||||||
|
name: 'Error',
|
||||||
|
message: 'Forbidden',
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
|
||||||
|
|
||||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
}
|
}
|
||||||
@@ -25,7 +23,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
|||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
const res = await fetch(path, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...rest,
|
...rest,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
describe('auth client origin contract', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ session: null, user: null }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const { authClient } = await import('./auth-client');
|
||||||
|
await authClient.getSession();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const firstCall = fetchMock.mock.calls.at(0);
|
||||||
|
expect(firstCall).toBeDefined();
|
||||||
|
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
||||||
|
expect(requestURL.origin).toBe(window.location.origin);
|
||||||
|
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||||
|
|
||||||
|
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
||||||
|
// every browser request on the current origin in development and production.
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
|
||||||
plugins: [adminClient(), genericOAuthClient()],
|
plugins: [adminClient(), genericOAuthClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveAuthCallbackURL } from './auth-redirect';
|
||||||
|
|
||||||
|
const CURRENT_ORIGIN = 'https://mosaic.example';
|
||||||
|
|
||||||
|
describe('resolveAuthCallbackURL', () => {
|
||||||
|
it('preserves a canonical same-origin path with search and hash', () => {
|
||||||
|
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
||||||
|
'/projects?view=active#current',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
'chat',
|
||||||
|
'//evil.example',
|
||||||
|
'/..//evil.com',
|
||||||
|
'/..//evil.com/x',
|
||||||
|
'/./..//evil.com',
|
||||||
|
'/../..//evil.com',
|
||||||
|
'/foo/..//evil.com',
|
||||||
|
'/\\evil.example',
|
||||||
|
'/\n//evil.example',
|
||||||
|
'/\r//evil.example',
|
||||||
|
'/\t//evil.example',
|
||||||
|
'https://evil.example/phish',
|
||||||
|
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
||||||
|
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a canonical same-origin path for post-auth navigation.
|
||||||
|
*
|
||||||
|
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
||||||
|
* variants, and control characters that the WHATWG parser normalizes away.
|
||||||
|
*/
|
||||||
|
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
||||||
|
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expectedOrigin = new URL(currentOrigin).origin;
|
||||||
|
const resolved = new URL(candidate, expectedOrigin);
|
||||||
|
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
||||||
|
return DEFAULT_AUTH_CALLBACK_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_AUTH_CALLBACK_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
||||||
|
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
||||||
|
// time, so this introduces no runtime dependency — it only reuses the exact
|
||||||
|
// payload shapes instead of redeclaring them.
|
||||||
|
import type { Socket } from 'socket.io-client';
|
||||||
|
import type {
|
||||||
|
AbortPayload,
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentStartPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
ChatMessagePayload,
|
||||||
|
ClientToServerEvents,
|
||||||
|
CommandDef,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
MessageAckPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
|
ServerToClientEvents,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SetThinkingPayload,
|
||||||
|
SkillCommandDef,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AbortPayload,
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentStartPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
ChatMessagePayload,
|
||||||
|
ClientToServerEvents,
|
||||||
|
CommandDef,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
MessageAckPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
|
ServerToClientEvents,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SetThinkingPayload,
|
||||||
|
SkillCommandDef,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
|
||||||
|
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
interface MockChatSocket {
|
||||||
|
on: ReturnType<typeof vi.fn>;
|
||||||
|
offAny: ReturnType<typeof vi.fn>;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
/** Test-only helper: fires every handler registered for `event` via
|
||||||
|
* `.on`, mirroring how a real socket.io-client instance invokes its own
|
||||||
|
* listeners (e.g. calling the registered `disconnect` handler(s) on a
|
||||||
|
* real transient disconnect). */
|
||||||
|
trigger(event: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockSocket(): MockChatSocket {
|
||||||
|
const handlers = new Map<string, Set<() => void>>();
|
||||||
|
const mockSocket: MockChatSocket = {
|
||||||
|
on: vi.fn((event: string, handler: () => void) => {
|
||||||
|
if (!handlers.has(event)) handlers.set(event, new Set());
|
||||||
|
handlers.get(event)?.add(handler);
|
||||||
|
return mockSocket;
|
||||||
|
}),
|
||||||
|
offAny: vi.fn(() => mockSocket),
|
||||||
|
disconnect: vi.fn(() => mockSocket),
|
||||||
|
trigger(event: string): void {
|
||||||
|
for (const handler of handlers.get(event) ?? []) handler();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mockSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentMock!: MockChatSocket;
|
||||||
|
|
||||||
|
describe('chat socket', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ioMock.mockReset();
|
||||||
|
// A fresh object per io() call so identity assertions (same singleton vs.
|
||||||
|
// a genuinely new instance) are meaningful.
|
||||||
|
ioMock.mockImplementation(() => {
|
||||||
|
currentMock = createMockSocket();
|
||||||
|
return currentMock;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
destroySocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates one same-origin /chat namespace socket', () => {
|
||||||
|
const first = getSocket();
|
||||||
|
const second = getSocket();
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(ioMock).toHaveBeenCalledOnce();
|
||||||
|
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
||||||
|
withCredentials: true,
|
||||||
|
autoConnect: false,
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the same singleton instance across a transient disconnect', () => {
|
||||||
|
const first = getSocket();
|
||||||
|
|
||||||
|
// socket.ts must not react to a real socket's `disconnect` event by
|
||||||
|
// nulling the singleton — it registers no such handler at all now.
|
||||||
|
// Actually fire every handler registered via `.on('disconnect', ...)`
|
||||||
|
// (mirroring a real socket.io-client reconnect) instead of merely
|
||||||
|
// calling getSocket() again: this is what makes the test fail if
|
||||||
|
// production reintroduces `socket.on('disconnect', () => { socket =
|
||||||
|
// null; })`, since that handler would run here and null the singleton
|
||||||
|
// before the next getSocket() call.
|
||||||
|
currentMock.trigger('disconnect');
|
||||||
|
const second = getSocket();
|
||||||
|
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(ioMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only creates a new singleton after an explicit destroySocket()', () => {
|
||||||
|
const first = getSocket();
|
||||||
|
|
||||||
|
destroySocket();
|
||||||
|
const second = getSocket();
|
||||||
|
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(ioMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-13
@@ -1,23 +1,27 @@
|
|||||||
import { io, type Socket } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
import type { ChatSocket } from './chat-contract';
|
||||||
|
|
||||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
let socket: ChatSocket | null = null;
|
||||||
|
|
||||||
let socket: Socket | null = null;
|
export function getSocket(): ChatSocket {
|
||||||
|
|
||||||
export function getSocket(): Socket {
|
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
socket = io(`${GATEWAY_URL}/chat`, {
|
// socket.io-client 4.8.3's `io()` factory declaration always returns the
|
||||||
|
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
|
||||||
|
// generics), so this one cast is the unavoidable boundary between that and the
|
||||||
|
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
|
||||||
|
// with no further assertions.
|
||||||
|
socket = io('/chat', {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
});
|
}) as unknown as ChatSocket;
|
||||||
|
|
||||||
// Reset singleton reference when socket is fully closed so the next
|
// A transient `disconnect` (network blip, server restart) must NOT null
|
||||||
// getSocket() call creates a fresh instance instead of returning a
|
// the singleton: socket.io-client auto-reconnects this same instance,
|
||||||
// closed/dead socket.
|
// and its listeners stay registered across that reconnect. Nulling here
|
||||||
socket.on('disconnect', () => {
|
// previously orphaned those listeners on the next getSocket() call by
|
||||||
socket = null;
|
// handing back a brand-new, unconnected instance. Only destroySocket()
|
||||||
});
|
// (an explicit, intentional teardown) may reset the singleton.
|
||||||
}
|
}
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
|
||||||
|
|
||||||
describe('sso-providers', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the enabled providers in login button order', () => {
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
|
||||||
|
|
||||||
expect(getEnabledSsoProviders()).toEqual([
|
|
||||||
{
|
|
||||||
id: 'workos',
|
|
||||||
buttonLabel: 'Continue with WorkOS',
|
|
||||||
description: 'Enterprise SSO via WorkOS',
|
|
||||||
enabled: true,
|
|
||||||
href: '/auth/provider/workos',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
enabled: true,
|
|
||||||
href: '/auth/provider/keycloak',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
|
||||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
|
||||||
|
|
||||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
|
||||||
expect(getSsoProvider('keycloak')).toEqual({
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
enabled: false,
|
|
||||||
href: '/auth/provider/keycloak',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for unknown providers', () => {
|
|
||||||
expect(getSsoProvider('authentik')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
export type SsoProviderId = 'workos' | 'keycloak';
|
|
||||||
|
|
||||||
export interface SsoProvider {
|
|
||||||
id: SsoProviderId;
|
|
||||||
buttonLabel: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
|
||||||
workos: {
|
|
||||||
id: 'workos',
|
|
||||||
buttonLabel: 'Continue with WorkOS',
|
|
||||||
description: 'Enterprise SSO via WorkOS',
|
|
||||||
},
|
|
||||||
keycloak: {
|
|
||||||
id: 'keycloak',
|
|
||||||
buttonLabel: 'Continue with Keycloak',
|
|
||||||
description: 'Enterprise SSO via Keycloak',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
|
||||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
|
||||||
.map((providerId) => getSsoProvider(providerId))
|
|
||||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
|
||||||
if (!isSsoProviderId(providerId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...PROVIDER_METADATA[providerId],
|
|
||||||
enabled: isSsoProviderEnabled(providerId),
|
|
||||||
href: `/auth/provider/${providerId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
|
||||||
return value === 'workos' || value === 'keycloak';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
|
||||||
switch (providerId) {
|
|
||||||
case 'workos':
|
|
||||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
|
||||||
case 'keycloak':
|
|
||||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from '@/providers/theme-provider';
|
||||||
|
import { createAppRouter } from '@/routes';
|
||||||
|
import '@/app/globals.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('missing #root element');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<RouterProvider router={createAppRouter()} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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 { ChatPage } from '@/spa/pages/chat';
|
||||||
|
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
||||||
|
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: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||||
|
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
||||||
|
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
||||||
|
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
||||||
|
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||||
|
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
||||||
|
return createBrowserRouter(routes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { CommandsPanel } from './commands-panel';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement | null;
|
||||||
|
|
||||||
|
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CommandsPanel', () => {
|
||||||
|
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
// Free-text server message claims a different, less alarming target
|
||||||
|
// than what will actually be sent — the UI must not rely on this.
|
||||||
|
message: 'This will only affect the staging environment.',
|
||||||
|
}}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exact frozen combined action is visible...
|
||||||
|
expect(container?.textContent).toContain('/deploy');
|
||||||
|
expect(container?.textContent).toContain('prod');
|
||||||
|
// ...and the misleading server free-text is never shown next to it.
|
||||||
|
expect(container?.textContent).not.toContain('staging environment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when a manifest commands entry is null', async () => {
|
||||||
|
const manifest = {
|
||||||
|
commands: [
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
aliases: [],
|
||||||
|
description: 'Change the active model',
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [null],
|
||||||
|
version: 1,
|
||||||
|
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={manifest}
|
||||||
|
results={[]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
}}
|
||||||
|
pendingApproval={{ command: 'deploy' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders skills from a skills-only manifest', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={{
|
||||||
|
commands: [],
|
||||||
|
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
||||||
|
version: 1,
|
||||||
|
}}
|
||||||
|
results={[]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('brave-search');
|
||||||
|
expect(container?.textContent).toContain('Search the web');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
||||||
|
const approval = {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: { truthy: 'object' },
|
||||||
|
approvalId: { also: 'object' },
|
||||||
|
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={approval}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
||||||
|
button.textContent?.includes('Run approved command'),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized',
|
||||||
|
}}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('Not authorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[]}
|
||||||
|
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
||||||
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||||
|
hasConversation
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('Denied.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[
|
||||||
|
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
||||||
|
{ conversationId: 'c1', command: 'deploy', success: false },
|
||||||
|
]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('Unknown model');
|
||||||
|
expect(container?.textContent).toContain('Command failed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
||||||
|
const hostileMessage = 'y'.repeat(50_000);
|
||||||
|
await render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={null}
|
||||||
|
results={[
|
||||||
|
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
||||||
|
]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = container?.textContent ?? '';
|
||||||
|
expect(text.length).toBeLessThan(hostileMessage.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
||||||
|
const manifest = {
|
||||||
|
commands: 'not-an-array',
|
||||||
|
skills: null,
|
||||||
|
version: 1,
|
||||||
|
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
render(
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={manifest}
|
||||||
|
results={[]}
|
||||||
|
approval={null}
|
||||||
|
pendingApproval={null}
|
||||||
|
hasConversation={false}
|
||||||
|
onExecute={vi.fn()}
|
||||||
|
onApprove={vi.fn()}
|
||||||
|
onRunApproved={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState, type ReactElement } from 'react';
|
||||||
|
import type { PendingApproval } from './use-chat-connection';
|
||||||
|
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
||||||
|
import { asNonEmptyString, asString } from './runtime-guards';
|
||||||
|
import type {
|
||||||
|
CommandManifest,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
} from '@/lib/chat-contract';
|
||||||
|
|
||||||
|
/** Stable fallback copy shown for a failed command only when the server's
|
||||||
|
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
||||||
|
* malformed — the structured contract reason itself is otherwise shown
|
||||||
|
* directly, never a raw thrown exception, stack trace, or object value. */
|
||||||
|
const COMMAND_FAILURE_COPY = 'Command failed.';
|
||||||
|
|
||||||
|
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
||||||
|
* stored command:result message at ingestion, but this component must never
|
||||||
|
* assume every caller went through that path — bounding again here means a
|
||||||
|
* hostile/oversized message can never force an unbounded render. */
|
||||||
|
function boundMessage(value: string): string {
|
||||||
|
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
||||||
|
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandsPanelProps {
|
||||||
|
manifest: CommandManifest | null;
|
||||||
|
results: SlashCommandResultPayload[];
|
||||||
|
approval: SlashCommandApprovalResultPayload | null;
|
||||||
|
pendingApproval: PendingApproval | null;
|
||||||
|
hasConversation: boolean;
|
||||||
|
onExecute: (input: { command: string; args?: string }) => void;
|
||||||
|
onApprove: (input: { command: string; args?: string }) => void;
|
||||||
|
onRunApproved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandsPanel({
|
||||||
|
manifest,
|
||||||
|
results,
|
||||||
|
approval,
|
||||||
|
pendingApproval,
|
||||||
|
hasConversation,
|
||||||
|
onExecute,
|
||||||
|
onApprove,
|
||||||
|
onRunApproved,
|
||||||
|
}: CommandsPanelProps): ReactElement {
|
||||||
|
const [command, setCommand] = useState('');
|
||||||
|
const [args, setArgs] = useState('');
|
||||||
|
|
||||||
|
// Defense-in-depth: the reducer already normalizes success/approvalId
|
||||||
|
// before storing `approval`, but a matching command string alone must
|
||||||
|
// never be trusted here either — require the literal boolean `true` and a
|
||||||
|
// non-empty string approvalId, not merely truthy values.
|
||||||
|
const canRunApproved =
|
||||||
|
approval?.success === true &&
|
||||||
|
typeof approval.approvalId === 'string' &&
|
||||||
|
approval.approvalId.length > 0 &&
|
||||||
|
!!pendingApproval &&
|
||||||
|
pendingApproval.command === approval.command;
|
||||||
|
|
||||||
|
// A manifest arrives from the server as untyped JSON at runtime — guard
|
||||||
|
// both collections before mapping so a malformed manifest cannot throw.
|
||||||
|
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
||||||
|
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
||||||
|
{commands.length > 0 ? (
|
||||||
|
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
||||||
|
{commands.map((cmd, index) => (
|
||||||
|
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
||||||
|
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{skills.length > 0 ? (
|
||||||
|
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
||||||
|
{skills.map((skill, index) => (
|
||||||
|
<li key={asString(skill?.name) || `skill-${index}`}>
|
||||||
|
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
aria-label="Command name"
|
||||||
|
value={command}
|
||||||
|
onChange={(event) => setCommand(event.target.value)}
|
||||||
|
placeholder="command"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Command arguments"
|
||||||
|
value={args}
|
||||||
|
onChange={(event) => setArgs(event.target.value)}
|
||||||
|
placeholder="args (optional)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasConversation || !command.trim()}
|
||||||
|
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
||||||
|
>
|
||||||
|
Run command
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasConversation || !command.trim()}
|
||||||
|
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
||||||
|
>
|
||||||
|
Request approval
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{approval ? (
|
||||||
|
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
||||||
|
{/* A successful approval shows stable client copy only — never
|
||||||
|
the server-controlled approval.message or echoed
|
||||||
|
approval.command as the primary confirmation. The frozen local
|
||||||
|
pendingApproval below (not this line) is the sole authoritative
|
||||||
|
statement of what will run. A denial, by contrast, is not an
|
||||||
|
execution authority and safely surfaces the guarded structured
|
||||||
|
reason the server gave (e.g. "Not authorized"), falling back to
|
||||||
|
a stable copy only when absent/malformed. */}
|
||||||
|
<span>
|
||||||
|
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
||||||
|
</span>
|
||||||
|
{canRunApproved && pendingApproval ? (
|
||||||
|
<>
|
||||||
|
{/* Authoritative frozen local command+args — what the click below
|
||||||
|
will actually emit. The server's `approval` above is display-only
|
||||||
|
and must never be trusted to represent the executed payload. */}
|
||||||
|
<span>
|
||||||
|
Will run: /{pendingApproval.command}{' '}
|
||||||
|
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onRunApproved}>
|
||||||
|
Run approved command
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<ul aria-label="Command results" className="flex flex-col gap-1">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
||||||
|
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
||||||
|
{result.success
|
||||||
|
? typeof result.message === 'string' && result.message
|
||||||
|
? ` — ${boundMessage(result.message)}`
|
||||||
|
: ''
|
||||||
|
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||||
|
|
||||||
|
interface ComposerProps {
|
||||||
|
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||||
|
onStop: () => void;
|
||||||
|
streaming: boolean;
|
||||||
|
/** True from local send time through server turn startup/ack and
|
||||||
|
* throughout streaming — a superset of `streaming` that also covers the
|
||||||
|
* pre-ack window where a second send could otherwise slip through. */
|
||||||
|
sending: boolean;
|
||||||
|
hasConversation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
onSend,
|
||||||
|
onStop,
|
||||||
|
streaming,
|
||||||
|
sending,
|
||||||
|
hasConversation,
|
||||||
|
}: ComposerProps): ReactElement {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [provider, setProvider] = useState('');
|
||||||
|
const [modelId, setModelId] = useState('');
|
||||||
|
const busy = streaming || sending;
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (busy) return;
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onSend({
|
||||||
|
content: trimmed,
|
||||||
|
provider: provider.trim() || undefined,
|
||||||
|
modelId: modelId.trim() || undefined,
|
||||||
|
});
|
||||||
|
setContent('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-2 border-t p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
aria-label="Provider"
|
||||||
|
value={provider}
|
||||||
|
onChange={(event) => setProvider(event.target.value)}
|
||||||
|
placeholder="Provider (optional)"
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Model"
|
||||||
|
value={modelId}
|
||||||
|
onChange={(event) => setModelId(event.target.value)}
|
||||||
|
placeholder="Model (optional)"
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
aria-label="Message"
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
||||||
|
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!content.trim() || busy}
|
||||||
|
className="rounded px-3 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Stop"
|
||||||
|
disabled={!hasConversation || !streaming}
|
||||||
|
onClick={onStop}
|
||||||
|
className="rounded px-3 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
||||||
|
* flood any of these collections; caps keep memory/render cost flat instead
|
||||||
|
* of growing unboundedly for the lifetime of the connection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
||||||
|
export const MAX_STREAM_CHARS = 20_000;
|
||||||
|
/** Max transcript turns retained (oldest dropped first). */
|
||||||
|
export const MAX_MESSAGES = 500;
|
||||||
|
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
||||||
|
export const MAX_TOOLS = 200;
|
||||||
|
/** Max slash-command results retained. */
|
||||||
|
export const MAX_COMMAND_RESULTS = 200;
|
||||||
|
/** Max commands/skills accepted from a single manifest push. */
|
||||||
|
export const MAX_MANIFEST_ITEMS = 500;
|
||||||
|
/** Max executed approval IDs remembered for single-flight dedup. */
|
||||||
|
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
||||||
|
/** Max characters retained for a single command:result message — a hostile
|
||||||
|
* or malfunctioning gateway must not be able to push an unbounded curated
|
||||||
|
* success/failure reason into state (or, defensively, onto the page). */
|
||||||
|
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { ChatTranscriptMessage } from './use-chat-connection';
|
||||||
|
|
||||||
|
interface MessageTranscriptProps {
|
||||||
|
messages: ChatTranscriptMessage[];
|
||||||
|
streaming: boolean;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageTranscript({
|
||||||
|
messages,
|
||||||
|
streaming,
|
||||||
|
text,
|
||||||
|
}: MessageTranscriptProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Conversation"
|
||||||
|
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
||||||
|
>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
||||||
|
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
||||||
|
<span>{message.text}</span>
|
||||||
|
{message.thinking ? (
|
||||||
|
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{streaming ? (
|
||||||
|
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
||||||
|
<span className="font-medium">Assistant: </span>
|
||||||
|
<span>{text || 'Thinking…'}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Socket.IO payloads are only statically typed at the call site — a
|
||||||
|
* misbehaving or compromised gateway can send anything at runtime. These
|
||||||
|
* guards protect the dereference sites that would otherwise throw (`.map` on
|
||||||
|
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
||||||
|
* child.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function asString(value: unknown, fallback = ''): string {
|
||||||
|
return typeof value === 'string' ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like `asString`, but an empty string also falls back — used for guarded
|
||||||
|
* contract-provided reason strings (e.g. a denial or failure message) where
|
||||||
|
* an empty string is not a meaningful value to display in place of the
|
||||||
|
* stable fallback copy. */
|
||||||
|
export function asNonEmptyString(value: unknown, fallback: string): string {
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asFiniteNumber(value: unknown, fallback = 0): number {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
|
||||||
|
* fallback — callers that must not fabricate a plausible-looking value (e.g.
|
||||||
|
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
|
||||||
|
* honest "unavailable" label instead. */
|
||||||
|
export function asFiniteNumberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asStringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single point of truth for what counts as a valid conversation ID
|
||||||
|
* anywhere a scoped server event may adopt one into state — a non-empty
|
||||||
|
* string, nothing else. Every site that establishes or compares
|
||||||
|
* `state.conversationId` against a raw socket payload must route through
|
||||||
|
* this guard so a malformed first frame (null/object/number/empty string)
|
||||||
|
* can never be adopted verbatim. */
|
||||||
|
export function asConversationId(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
||||||
|
import { MAX_MANIFEST_ITEMS } from './limits';
|
||||||
|
import { asString, asStringArray } from './runtime-guards';
|
||||||
|
|
||||||
|
interface SessionPanelProps {
|
||||||
|
sessionInfo: SessionInfoPayload | null;
|
||||||
|
onSetThinking: (level: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THINKING_LEVEL_UNAVAILABLE = '';
|
||||||
|
|
||||||
|
export function SessionPanel({
|
||||||
|
sessionInfo,
|
||||||
|
onSetThinking,
|
||||||
|
}: SessionPanelProps): ReactElement | null {
|
||||||
|
if (!sessionInfo) return null;
|
||||||
|
|
||||||
|
// The reducer already caps this before storing it, but the render site
|
||||||
|
// defends independently — a hostile payload must never be able to force
|
||||||
|
// this <select> to lay out an unbounded number of options.
|
||||||
|
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
|
||||||
|
0,
|
||||||
|
MAX_MANIFEST_ITEMS,
|
||||||
|
);
|
||||||
|
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Session info"
|
||||||
|
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
||||||
|
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span>Thinking level</span>
|
||||||
|
<select
|
||||||
|
aria-label="Thinking level"
|
||||||
|
value={
|
||||||
|
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
||||||
|
}
|
||||||
|
onChange={(event) => {
|
||||||
|
// The placeholder option is not a real, settable level — a
|
||||||
|
// malformed availableThinkingLevels list must never let the
|
||||||
|
// client emit set:thinking for it.
|
||||||
|
if (!hasThinkingLevels) return;
|
||||||
|
onSetThinking(event.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasThinkingLevels ? (
|
||||||
|
availableThinkingLevels.map((level) => (
|
||||||
|
<option key={level} value={level}>
|
||||||
|
{level}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{sessionInfo.routingDecision ? (
|
||||||
|
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
||||||
|
{asString(sessionInfo.routingDecision.reason)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
||||||
|
|
||||||
|
type ServerEvent = keyof ServerToClientEvents;
|
||||||
|
type ClientEvent = keyof ClientToServerEvents;
|
||||||
|
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
||||||
|
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
||||||
|
|
||||||
|
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||||
|
event: K;
|
||||||
|
payload: ClientPayload<K>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||||
|
export interface FakeChatSocket {
|
||||||
|
connected: boolean;
|
||||||
|
connect(): FakeChatSocket;
|
||||||
|
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||||
|
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||||
|
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
||||||
|
* `(event: string, payload: unknown) => void` mock, every public method here is
|
||||||
|
* checked against the real `/chat` contract — a typo'd event name or a payload
|
||||||
|
* missing a required field fails to compile instead of silently no-op'ing at
|
||||||
|
* runtime.
|
||||||
|
*/
|
||||||
|
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
||||||
|
* ServerToClientEvents contract, but real sockets always support them and
|
||||||
|
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
||||||
|
type LifecycleEvent = 'connect' | 'disconnect';
|
||||||
|
|
||||||
|
export function createFakeChatSocket(): {
|
||||||
|
socket: FakeChatSocket;
|
||||||
|
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
||||||
|
emitted: EmittedEvent[];
|
||||||
|
serverEmit<K extends ServerEvent>(
|
||||||
|
event: K,
|
||||||
|
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||||
|
): void;
|
||||||
|
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
||||||
|
* payload contract to simulate a genuinely untrusted runtime value from the
|
||||||
|
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
||||||
|
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
||||||
|
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
||||||
|
* registered via `socket.on('disconnect', ...)` without clearing any
|
||||||
|
* listeners, mirroring how a real reconnecting socket behaves. */
|
||||||
|
simulateDisconnect(): void;
|
||||||
|
/** Simulates socket.io-client's automatic reconnect of the *same*
|
||||||
|
* instance after a transient disconnect: marks the socket connected again
|
||||||
|
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
||||||
|
* without clearing or replacing any listeners. */
|
||||||
|
simulateReconnect(): void;
|
||||||
|
} {
|
||||||
|
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||||
|
const emitted: EmittedEvent[] = [];
|
||||||
|
|
||||||
|
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
||||||
|
// varies by K, which a single Map can't express); the generic signatures on the
|
||||||
|
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
||||||
|
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||||
|
const socket = {
|
||||||
|
connected: false,
|
||||||
|
connect: vi.fn(function connect(this: void) {
|
||||||
|
socket.connected = true;
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||||
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||||
|
listeners.get(event)?.add(handler);
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||||
|
listeners.get(event)?.delete(handler);
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
||||||
|
emitted.push({ event, payload } as EmittedEvent);
|
||||||
|
return socket;
|
||||||
|
}),
|
||||||
|
} as unknown as FakeChatSocket;
|
||||||
|
|
||||||
|
function serverEmit<K extends ServerEvent>(
|
||||||
|
event: K,
|
||||||
|
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||||
|
): void {
|
||||||
|
for (const handler of listeners.get(event) ?? []) {
|
||||||
|
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
||||||
|
for (const handler of listeners.get(event) ?? []) {
|
||||||
|
(handler as (payload: unknown) => void)(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateDisconnect(): void {
|
||||||
|
socket.connected = false;
|
||||||
|
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||||
|
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||||
|
(handler as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateReconnect(): void {
|
||||||
|
socket.connected = true;
|
||||||
|
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||||
|
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||||
|
(handler as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
listeners,
|
||||||
|
emitted,
|
||||||
|
serverEmit,
|
||||||
|
serverEmitRaw,
|
||||||
|
simulateDisconnect,
|
||||||
|
simulateReconnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ToolCallList } from './tool-call-list';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement | null;
|
||||||
|
|
||||||
|
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ToolCallList', () => {
|
||||||
|
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<ToolCallList
|
||||||
|
tools={[
|
||||||
|
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
||||||
|
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = [...(container?.querySelectorAll('li') ?? [])];
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0]?.textContent).toContain('success');
|
||||||
|
expect(items[1]?.textContent).toContain('running');
|
||||||
|
|
||||||
|
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
||||||
|
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
||||||
|
);
|
||||||
|
expect(duplicateKeyWarning).toBe(false);
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import type { ToolCallState } from './use-chat-connection';
|
||||||
|
|
||||||
|
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
||||||
|
if (tools.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
||||||
|
{tools.map((tool, index) => (
|
||||||
|
<li
|
||||||
|
// A valid server-controlled toolCallId can legitimately repeat
|
||||||
|
// (e.g. two tool:start events sharing one id) — keying on it alone
|
||||||
|
// would give React two identical keys. Pairing it with its
|
||||||
|
// (stable, append-only) render index keeps every key unique.
|
||||||
|
key={`${tool.toolCallId}-${index}`}
|
||||||
|
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
||||||
|
>
|
||||||
|
{tool.toolName} —{' '}
|
||||||
|
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { useSessionMock } = vi.hoisted(() => ({
|
||||||
|
useSessionMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-client', () => ({
|
||||||
|
useSession: useSessionMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AuthGuard, GuestGuard } from './guards';
|
||||||
|
|
||||||
|
interface RenderedRouter {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
router: ReturnType<typeof createMemoryRouter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountedRoots: Root[] = [];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderRouter(
|
||||||
|
routeObjects: RouteObject[],
|
||||||
|
initialEntry: string,
|
||||||
|
): Promise<RenderedRouter> {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
||||||
|
const root = createRoot(container);
|
||||||
|
mountedRoots.push(root);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<RouterProvider router={router} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { container, router };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const root of mountedRoots.splice(0)) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.body.replaceChildren();
|
||||||
|
useSessionMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const guestRoutes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <GuestGuard />,
|
||||||
|
children: [{ index: true, element: <p>Guest page</p> }],
|
||||||
|
},
|
||||||
|
{ path: '/chat', element: <p>Chat page</p> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const authenticatedRoutes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/chat',
|
||||||
|
element: <AuthGuard />,
|
||||||
|
children: [{ index: true, element: <p>Private page</p> }],
|
||||||
|
},
|
||||||
|
{ path: '/login', element: <p>Login page</p> },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('GuestGuard', () => {
|
||||||
|
it('renders the guest outlet while session lookup is pending', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||||
|
|
||||||
|
const view = await renderRouter(guestRoutes, '/login');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Guest page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the guest outlet when no session exists', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(guestRoutes, '/login');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Guest page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects an authenticated session to chat', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(guestRoutes, '/login');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Chat page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthGuard', () => {
|
||||||
|
it('renders the existing loading treatment while session lookup is pending', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||||
|
|
||||||
|
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Loading...');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects an unauthenticated visitor to login', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Login page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the authenticated outlet when a session exists', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||||
|
|
||||||
|
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||||
|
|
||||||
|
expect(view.container.textContent).toContain('Private page');
|
||||||
|
expect(view.router.state.location.pathname).toBe('/chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { useSession } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
export function GuestGuard(): ReactElement {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard(): ReactElement {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-sm text-text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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 { routes } from '@/routes';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
function Boom(): never {
|
||||||
|
throw new Error('render blew up');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively clones the real exported route table, replacing only the
|
||||||
|
* `/chat` route's `element` with `<Boom />` — every other route (including
|
||||||
|
* the real `AuthGuard` nesting and the real `/chat` `errorElement`) is left
|
||||||
|
* exactly as exported. This is what makes the test fail if a future change
|
||||||
|
* removes the real route's `errorElement`, unlike a hand-built independent
|
||||||
|
* route tree that could drift from production undetected. */
|
||||||
|
function replaceChatElementWithBoom(nodes: RouteObject[]): RouteObject[] {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const cloned: RouteObject = { ...node };
|
||||||
|
if (cloned.path === '/chat') {
|
||||||
|
cloned.element = <Boom />;
|
||||||
|
}
|
||||||
|
if (cloned.children) {
|
||||||
|
cloned.children = replaceChatElementWithBoom(cloned.children);
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
root = null;
|
||||||
|
useSessionMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatRouteErrorBoundary', () => {
|
||||||
|
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
|
||||||
|
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||||
|
|
||||||
|
const routeObjects = replaceChatElementWithBoom(routes);
|
||||||
|
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
|
||||||
|
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<RouterProvider router={router} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(container.textContent).not.toBe('');
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { useRouteError } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/chat` renders live, server-driven state (streamed text, tool calls,
|
||||||
|
* manifests) that can carry malformed payloads no compile-time contract can
|
||||||
|
* fully rule out at every dereference site. This is the last line of
|
||||||
|
* defense: if something still throws during render, show a recoverable
|
||||||
|
* alert instead of leaving the user on a blank/white screen.
|
||||||
|
*/
|
||||||
|
export function ChatRouteErrorBoundary(): ReactElement {
|
||||||
|
useRouteError();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
||||||
|
<p className="text-sm font-medium">Something went wrong loading chat.</p>
|
||||||
|
<a href="/chat" className="text-sm underline">
|
||||||
|
Reload chat
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
||||||
|
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
|
||||||
|
|
||||||
|
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
||||||
|
getSocketMock: vi.fn(),
|
||||||
|
destroySocketMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/socket', () => ({
|
||||||
|
getSocket: getSocketMock,
|
||||||
|
destroySocket: destroySocketMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChatPage } from './chat';
|
||||||
|
|
||||||
|
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
||||||
|
const proto =
|
||||||
|
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||||
|
setter?.call(el, value);
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectValue(el: HTMLSelectElement, value: string): void {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
||||||
|
setter?.call(el, value);
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||||
|
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes(text),
|
||||||
|
);
|
||||||
|
if (!button) throw new Error(`Button with text "${text}" not found`);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fake = createFakeChatSocket();
|
||||||
|
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||||
|
destroySocketMock.mockReset();
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<ChatPage />);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatPage', () => {
|
||||||
|
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
||||||
|
fake.serverEmit('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'web_search',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('pondering…');
|
||||||
|
expect(container.textContent).toContain('Hello!');
|
||||||
|
expect(container.textContent).toContain('web_search');
|
||||||
|
expect(container.textContent).toMatch(/running/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:tool:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'web_search',
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
fake.serverEmit('agent:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
usage: {
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
||||||
|
cost: 0.02,
|
||||||
|
context: { percent: 3, window: 200000 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toMatch(/success/i);
|
||||||
|
expect(container.textContent).toContain('Hello!');
|
||||||
|
expect(container.textContent).toMatch(/46/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('commands:manifest', {
|
||||||
|
manifest: {
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
aliases: ['m'],
|
||||||
|
description: 'Change the active model',
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fake.serverEmit('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||||
|
routingDecision: {
|
||||||
|
model: 'claude',
|
||||||
|
provider: 'anthropic',
|
||||||
|
ruleName: 'default',
|
||||||
|
reason: 'default routing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('model');
|
||||||
|
expect(container.textContent).toContain('Change the active model');
|
||||||
|
expect(container.textContent).toContain('anthropic');
|
||||||
|
expect(container.textContent).toContain('default routing');
|
||||||
|
|
||||||
|
const select = container.querySelector(
|
||||||
|
'select[aria-label="Thinking level"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select).toBeTruthy();
|
||||||
|
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(select, 'high');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'set:thinking',
|
||||||
|
payload: { conversationId: 'c1', level: 'high' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandInput = container.querySelector(
|
||||||
|
'input[aria-label="Command name"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const argsInput = container.querySelector(
|
||||||
|
'input[aria-label="Command arguments"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(commandInput, 'model');
|
||||||
|
setValue(argsInput, 'gpt-5');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Run command').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(commandInput, 'deploy');
|
||||||
|
setValue(argsInput, 'prod');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Request approval').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:approve',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toMatch(/approved/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Run approved command').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
||||||
|
fake.serverEmit('command:result', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'model',
|
||||||
|
success: false,
|
||||||
|
message: 'Unknown model',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||||
|
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||||
|
expect(alertText).toContain('The model is unavailable');
|
||||||
|
// The structured, contract-provided denial reason is visibly rendered.
|
||||||
|
expect(alertText).toContain('Unknown model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmitRaw('command:result', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'model',
|
||||||
|
success: false,
|
||||||
|
message: { bad: 'object' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||||
|
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||||
|
expect(alertText).toContain('Command failed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
|
||||||
|
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'level-0',
|
||||||
|
availableThinkingLevels: hostileLevels,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const select = container.querySelector(
|
||||||
|
'select[aria-label="Thinking level"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select).toBeTruthy();
|
||||||
|
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmitRaw('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||||
|
const select = container.querySelector(
|
||||||
|
'select[aria-label="Thinking level"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select).toBeTruthy();
|
||||||
|
// A malformed level list still shows a visible, safe placeholder option
|
||||||
|
// rather than a silently empty select.
|
||||||
|
expect([...select.options]).toHaveLength(1);
|
||||||
|
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(select, '');
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmitRaw('agent:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
usage: {
|
||||||
|
provider: { nested: 'object' },
|
||||||
|
modelId: undefined,
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
tokens: { total: 'not-a-number' },
|
||||||
|
cost: undefined,
|
||||||
|
context: { percent: null, window: 200000 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = container.querySelector('[aria-label="Usage"]');
|
||||||
|
expect(usage).toBeTruthy();
|
||||||
|
expect(usage?.textContent).toContain('tokens unavailable');
|
||||||
|
expect(usage?.textContent).toContain('cost unavailable');
|
||||||
|
expect(usage?.textContent).not.toContain('0 tokens');
|
||||||
|
expect(usage?.textContent).not.toContain('$0.0000');
|
||||||
|
expect(usage?.textContent).toContain('unknown/unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
||||||
|
node.textContent?.includes('Message accepted'),
|
||||||
|
);
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
// A malformed messageId gets a stable, visible fallback — never blank,
|
||||||
|
// never the raw object.
|
||||||
|
expect(status?.textContent).toContain('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('system:reload', {
|
||||||
|
commands: [],
|
||||||
|
skills: [],
|
||||||
|
providers: [],
|
||||||
|
message: { bad: 'object' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
const status = container.querySelector('[role="status"]');
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
// A malformed reload message renders a stable, visible fallback rather
|
||||||
|
// than a silently empty status line.
|
||||||
|
expect(status?.textContent).toContain('Commands reloaded.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
const providerInput = container.querySelector(
|
||||||
|
'input[aria-label="Provider"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
||||||
|
|
||||||
|
const stopButtonBefore = container.querySelector(
|
||||||
|
'button[aria-label="Stop"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(stopButtonBefore.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello there');
|
||||||
|
setValue(providerInput, 'anthropic');
|
||||||
|
setValue(modelInput, 'claude');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: undefined,
|
||||||
|
content: 'hello there',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain('hello there');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopButtonDuring = container.querySelector(
|
||||||
|
'button[aria-label="Stop"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(stopButtonDuring.disabled).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stopButtonDuring.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('anthropic');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('error', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
error: 'Failed to start agent session. Please try again.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||||
|
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
||||||
|
expect(stopButton.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an accessible status once the message is acknowledged', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const statuses = [...container.querySelectorAll('[role="status"]')];
|
||||||
|
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('reasoning about it');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('reasoning about it');
|
||||||
|
expect(container.textContent).toContain('Done.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandInput = container.querySelector(
|
||||||
|
'input[aria-label="Command name"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const argsInput = container.querySelector(
|
||||||
|
'input[aria-label="Command arguments"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(commandInput, 'deploy');
|
||||||
|
setValue(argsInput, 'prod');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Request approval').click();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
setValue(argsInput, 'staging');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Request approval').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:approve',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Run approved command').click();
|
||||||
|
findButton(container, 'Run approved command').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables sending a second message while a turn is streaming', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'first');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendButton = findButton(container, 'Send');
|
||||||
|
expect(sendButton.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'second');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
||||||
|
expect(fake.listeners.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
|
||||||
|
for (const [, handlers] of fake.listeners) {
|
||||||
|
expect(handlers.size).toBe(0);
|
||||||
|
}
|
||||||
|
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
||||||
|
import { Composer } from '@/spa/chat/composer';
|
||||||
|
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
||||||
|
import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
||||||
|
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||||
|
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||||
|
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||||
|
|
||||||
|
/** Renders a real value normally, but an honest "unavailable" label instead
|
||||||
|
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
||||||
|
* an unknown token count must never look the same. */
|
||||||
|
function formatTokens(value: unknown): string {
|
||||||
|
const tokens = asFiniteNumberOrNull(value);
|
||||||
|
return tokens === null ? 'tokens unavailable' : `${tokens} tokens`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same honesty guarantee as `formatTokens`, for cost. */
|
||||||
|
function formatCost(value: unknown): string {
|
||||||
|
const cost = asFiniteNumberOrNull(value);
|
||||||
|
return cost === null ? 'cost unavailable' : `$${cost.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPage(): ReactElement {
|
||||||
|
const { state, actions } = useChatConnection();
|
||||||
|
const hasConversation = state.conversationId !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
|
||||||
|
<header className="border-b px-4 py-3">
|
||||||
|
<h1 className="text-lg font-semibold">Chat</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{state.systemReload ? (
|
||||||
|
<div role="status" className="border-b px-4 py-2 text-sm">
|
||||||
|
{asString(state.systemReload.message)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.error ? (
|
||||||
|
<div role="alert" className="border-b px-4 py-2 text-sm">
|
||||||
|
{asString(state.error)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.ack ? (
|
||||||
|
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
||||||
|
Message accepted · conversation {asString(state.ack.conversationId, 'unknown')} · id{' '}
|
||||||
|
{asString(state.ack.messageId, 'unknown')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
|
||||||
|
|
||||||
|
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
|
||||||
|
|
||||||
|
{state.thinking ? (
|
||||||
|
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
|
||||||
|
{state.thinking}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ToolCallList tools={state.tools} />
|
||||||
|
|
||||||
|
{state.usage ? (
|
||||||
|
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
||||||
|
{formatTokens(state.usage.tokens?.total)} · {formatCost(state.usage.cost)} ·{' '}
|
||||||
|
{asString(state.usage.provider, 'unknown')}/{asString(state.usage.modelId, 'unknown')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<CommandsPanel
|
||||||
|
manifest={state.manifest}
|
||||||
|
results={state.commandResults}
|
||||||
|
approval={state.approval}
|
||||||
|
pendingApproval={state.pendingApproval}
|
||||||
|
hasConversation={hasConversation}
|
||||||
|
onExecute={actions.executeCommand}
|
||||||
|
onApprove={actions.approveCommand}
|
||||||
|
onRunApproved={actions.runApprovedCommand}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Composer
|
||||||
|
onSend={actions.sendMessage}
|
||||||
|
onStop={actions.abort}
|
||||||
|
streaming={state.streaming}
|
||||||
|
sending={state.sending}
|
||||||
|
hasConversation={hasConversation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { authClient, signIn } from '@/lib/auth-client';
|
||||||
|
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||||
|
|
||||||
|
export function LoginPage(): ReactElement {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||||
|
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||||
|
SsoProviderDiscovery['id'] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
||||||
|
(providers) => {
|
||||||
|
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (active) setSsoProviders([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const email = String(form.get('email') ?? '');
|
||||||
|
const password = String(form.get('password') ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signIn.email({ email, password });
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? 'Sign in failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/chat', { replace: true });
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setSsoLoadingProviderId(providerId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authClient.signIn.oauth2({
|
||||||
|
providerId,
|
||||||
|
callbackURL: '/chat',
|
||||||
|
newUserCallbackURL: '/chat',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
||||||
|
setSsoLoadingProviderId(null);
|
||||||
|
}
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
||||||
|
setSsoLoadingProviderId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<SsoProviderButtons
|
||||||
|
providers={ssoProviders}
|
||||||
|
loadingProviderId={ssoLoadingProviderId}
|
||||||
|
onOidcSignIn={(providerId) => {
|
||||||
|
void handleSsoSignIn(providerId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-text-muted">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState, type FormEvent, type ReactElement } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { signUp } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
export function RegisterPage(): ReactElement {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const name = String(form.get('name') ?? '');
|
||||||
|
const email = String(form.get('email') ?? '');
|
||||||
|
const password = String(form.get('password') ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signUp.email({ name, email, password });
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error.message ?? 'Registration failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/chat', { replace: true });
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-text-muted">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
||||||
|
apiMock: vi.fn(),
|
||||||
|
oauth2Mock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-client', () => ({
|
||||||
|
signIn: { oauth2: oauth2Mock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SsoCallbackPage } from './sso-callback';
|
||||||
|
|
||||||
|
const mountedRoots: Root[] = [];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
apiMock.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'authentik',
|
||||||
|
name: 'Authentik',
|
||||||
|
protocols: ['oidc'],
|
||||||
|
configured: true,
|
||||||
|
loginMode: 'oidc',
|
||||||
|
callbackPath: '/api/auth/oauth2/callback/authentik',
|
||||||
|
teamSync: { enabled: false, claim: null },
|
||||||
|
samlFallback: { configured: false, loginUrl: null },
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const root of mountedRoots.splice(0)) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.body.replaceChildren();
|
||||||
|
apiMock.mockReset();
|
||||||
|
oauth2Mock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SsoCallbackPage', () => {
|
||||||
|
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
||||||
|
const router = createMemoryRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: '/auth/provider/:provider',
|
||||||
|
element: <SsoCallbackPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
mountedRoots.push(root);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<RouterProvider router={router} />);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(oauth2Mock).toHaveBeenCalledWith({
|
||||||
|
providerId: 'authentik',
|
||||||
|
callbackURL: '/chat',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useState, type ReactElement } from 'react';
|
||||||
|
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||||
|
import { signIn } from '@/lib/auth-client';
|
||||||
|
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||||
|
|
||||||
|
export function SsoCallbackPage(): ReactElement {
|
||||||
|
const { provider: providerId = '' } = useParams<'provider'>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||||
|
const [providerName, setProviderName] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function redirectToProvider(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||||
|
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||||
|
if (!provider) {
|
||||||
|
setError('Unknown SSO provider.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProviderName(provider.name);
|
||||||
|
if (!provider.configured) {
|
||||||
|
setError(`${provider.name} is not enabled in this deployment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (provider.loginMode !== 'oidc') {
|
||||||
|
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signIn.oauth2({
|
||||||
|
providerId: provider.id,
|
||||||
|
callbackURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelled && result?.error) {
|
||||||
|
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||||
|
}
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void redirectToProvider();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [providerId, requestedCallbackURL]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||||
|
<p className="mt-2 text-sm text-text-secondary">
|
||||||
|
{providerName
|
||||||
|
? `Redirecting you to ${providerName}...`
|
||||||
|
: 'Preparing your sign-in request...'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||||
|
>
|
||||||
|
<p>{error}</p>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Return to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||||
|
If the redirect does not start automatically, return to the login page and try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
|
export function Placeholder({ title }: { title: string }): ReactElement {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center">
|
||||||
|
<h1 className="text-xl font-medium">{title}</h1>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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';
|
||||||
|
import { ChatPage } from '@/spa/pages/chat';
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('renders the real chat page instead of the P1 placeholder at /chat, inside the authenticated group', () => {
|
||||||
|
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||||
|
expect(authPaths).toContain('/chat');
|
||||||
|
|
||||||
|
const element = findRoute(routes, '/chat')?.element;
|
||||||
|
expect(isValidElement(element)).toBe(true);
|
||||||
|
if (!isValidElement(element)) throw new Error('Missing route element for /chat');
|
||||||
|
expect(element.type).not.toBe(Placeholder);
|
||||||
|
expect(element.type).toBe(ChatPage);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('Vitest abort-controller realm', () => {
|
||||||
|
it('provides a global signal accepted by Node native Request', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const request = new Request('https://mosaic.invalid/navigation', {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
controller.abort();
|
||||||
|
expect(request.signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { transferableAbortController } from 'node:util';
|
||||||
|
|
||||||
|
// jsdom installs realm-local abort constructors while Node's undici Request
|
||||||
|
// remains native. React Router passes a global AbortSignal to Request, so both
|
||||||
|
// constructors must come from Node's native realm during tests.
|
||||||
|
const nativeController = transferableAbortController();
|
||||||
|
const nativeAbortController = nativeController.constructor;
|
||||||
|
const nativeAbortSignal = nativeController.signal.constructor;
|
||||||
|
|
||||||
|
for (const target of [globalThis, window]) {
|
||||||
|
Object.defineProperties(target, {
|
||||||
|
AbortController: {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: nativeAbortController,
|
||||||
|
},
|
||||||
|
AbortSignal: {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: nativeAbortSignal,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
|
||||||
|
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
|
||||||
|
// request uses a relative path, so no origin may ever be configured here or in src/.
|
||||||
|
const gatewayTarget = 'http://localhost:14242';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3100,
|
||||||
|
proxy: {
|
||||||
|
'/api': gatewayTarget,
|
||||||
|
'/socket.io': { target: gatewayTarget, ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||||
|
esbuild: {
|
||||||
|
jsx: 'automatic',
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
isolate: true,
|
||||||
exclude: ['e2e/**', 'node_modules/**'],
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
COPY plugins/ ./plugins/
|
||||||
|
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||||
@@ -21,11 +23,22 @@ RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
# WorkspaceService shells out to git at runtime and roots workspaces at
|
||||||
|
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
||||||
|
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
||||||
|
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
||||||
|
# git was declined so routine base-image security updates remain maintainable.
|
||||||
|
RUN apk add --no-cache git \
|
||||||
|
&& mkdir -p /opt/mosaic/.workspaces \
|
||||||
|
&& chown -R node:node /opt/mosaic /app
|
||||||
|
ENV MOSAIC_ROOT=/opt/mosaic
|
||||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||||
COPY --from=builder /deploy/node_modules ./node_modules
|
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
||||||
COPY --from=builder /deploy/package.json ./package.json
|
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
||||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
# copy from builder explicitly as belt-and-suspenders
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --from=builder /app/apps/gateway/dist ./dist
|
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
||||||
EXPOSE 4000
|
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
||||||
|
EXPOSE 14242
|
||||||
|
USER node
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ WORKDIR /app
|
|||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
|
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @mosaic/web build
|
RUN pnpm --filter @mosaicstack/web build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
-33
@@ -1368,36 +1368,3 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
|||||||
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
||||||
|
|
||||||
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Greenfield install correctness — C1 (#1050)
|
|
||||||
|
|
||||||
### Problem and objective
|
|
||||||
|
|
||||||
A from-zero install can report success while leaving the target host unusable because the installer has no transactional state machine capable of certifying its own postconditions. C1 supplies the structural spine and red-first fixture; later cards repair the individual failed postconditions.
|
|
||||||
|
|
||||||
### Normative requirements
|
|
||||||
|
|
||||||
1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. P2 is scoped to installer-distribution artifacts and SHALL NOT foreclose credentialed downstream acquisition. P5 owns validating any credential capability required by requested downstream work; P7 may provision credential-dependent resources only after that P5 postcondition commits.
|
|
||||||
2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently.
|
|
||||||
3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded.
|
|
||||||
4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails.
|
|
||||||
5. P4 SHALL consume a checkout-free, lane/versioned shipped-set declaration published by the installer. C1 SHALL NOT select among the currently disagreeing framework-payload, repository-root, sync-source, and W-jarvis populations; while no declaration exists, P4 reports `NOT-MEASURED / UNDECLARED` and remains blocking rather than fabricating a count. C5 owns the declaration's contents and containment/loadability fulfillment.
|
|
||||||
6. The from-zero fixture SHALL be lane-parametric, use Debian/glibc, run the documented install command as a non-root target user with an isolated HOME, and inherit no host credentials, npm cache, home directory, or runtime configuration.
|
|
||||||
7. The fixture SHALL select `next` with `--next` or `MOSAIC_NEXT=1` and assert the resolved lane version. Internal predicates use P3's absolute CLI path; shell discoverability is tested only at P8.
|
|
||||||
8. Fault injection after each P2–P8 phase SHALL prove either clean rollback or a durable, honestly reported resumable partial state, with no journal incorrectly left in progress.
|
|
||||||
9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass.
|
|
||||||
|
|
||||||
### C1 acceptance criteria
|
|
||||||
|
|
||||||
1. The pre-C1 from-zero matrix records both discriminating controls: with `git` absent, the legacy installer still exits zero while P1 fails and skill sync degrades; with `git` present, P1 passes and the observed sync store/runtime links are 101/101. The C1 installer must fail at P1 before mutation when `git` is absent.
|
|
||||||
2. The discriminating P3 row passes: the binary exists at the expected absolute path and reports exactly the resolved `next` lane version, while P4, P5, and P8 fail.
|
|
||||||
3. The `--check` mutation negative control proves host fingerprints are byte-identical before and after observation.
|
|
||||||
4. Woodpecker executes and validates the expected RED fixture; C1 does not repair P4/P5/P8 or activate #869.
|
|
||||||
|
|
||||||
### Explicit exclusions and dependencies
|
|
||||||
|
|
||||||
- C2 owns P8/PATH, C3 owns P5/headless identity, C4 owns P6 activation policy, and C5 owns P4/skills.
|
|
||||||
- Main-lane execution is a promotion precondition owned by #1037; C1 only makes the fixture lane-parametric.
|
|
||||||
- RM-02 and #869 activation are out of scope.
|
|
||||||
|
|||||||
@@ -9,11 +9,6 @@
|
|||||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||||
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
||||||
|
|
||||||
## Installation and upgrades
|
|
||||||
|
|
||||||
- [Installer state machine and recovery](guides/installer-state-machine.md) — canonical P0–P9 phases, side-effect-free checks, durable journal states, rollback/remediation, and the Debian greenfield CI gate.
|
|
||||||
- [Upgrade safety and recovery](guides/upgrade-safety-and-recovery.md) — framework ownership, durable operator snapshots, verify net, and projection regeneration.
|
|
||||||
|
|
||||||
## CLI and skill management
|
## CLI and skill management
|
||||||
|
|
||||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# npm `@next` prerelease lane
|
||||||
|
|
||||||
|
Status: **IMPLEMENTED**
|
||||||
|
|
||||||
|
## Current behavior
|
||||||
|
|
||||||
|
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
|
||||||
|
|
||||||
|
The lane is fast-by-default:
|
||||||
|
|
||||||
|
1. Install framework files from the `next` source archive.
|
||||||
|
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm view @mosaicstack/gateway@next version
|
||||||
|
npm view @mosaicstack/mosaic@next version
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
|
||||||
|
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
|
||||||
|
|
||||||
|
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
|
||||||
|
|
||||||
|
## Published packages
|
||||||
|
|
||||||
|
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed `next` dist-tags after enabling the pipeline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
@mosaicstack/mosaic@next -> 0.0.49-next.1633
|
||||||
|
@mosaicstack/gateway@next -> 0.0.7-next.1633
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
|
||||||
|
|
||||||
|
## Explicit source lanes
|
||||||
|
|
||||||
|
Source builds remain available and are still the authority for explicit ref validation:
|
||||||
|
|
||||||
|
- `--dev` always builds from source.
|
||||||
|
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
|
||||||
|
|
||||||
|
## Pipeline shape
|
||||||
|
|
||||||
|
1. Trigger on `next` merges.
|
||||||
|
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
|
||||||
|
3. Build and publish non-private packages in CI.
|
||||||
|
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
|
||||||
|
5. Keep `latest` untouched; only main/release promotion can update `latest`.
|
||||||
|
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- `@next` is mutable prerelease convenience, not a deployment pin.
|
||||||
|
- Stable installs continue to use `@latest`.
|
||||||
|
- Contributor validation remains available through `--dev --ref <branch>`.
|
||||||
|
- Pipeline output traces every prerelease package back to the source commit on `next`.
|
||||||
|
- The installer falls back to source rather than hard-failing on prerelease registry issues.
|
||||||
@@ -195,6 +195,17 @@ pnpm format:check && pnpm typecheck && pnpm lint
|
|||||||
|
|
||||||
A pre-push hook enforces this mechanically.
|
A pre-push hook enforces this mechanically.
|
||||||
|
|
||||||
|
### CI Publish Channels
|
||||||
|
|
||||||
|
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
|
||||||
|
|
||||||
|
| Source | npm packages | Gateway image |
|
||||||
|
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
|
||||||
|
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
|
||||||
|
|
||||||
|
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding New Agent Tools
|
## Adding New Agent Tools
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
# Installer State Machine and Recovery
|
|
||||||
|
|
||||||
The unified installer uses a transactional P0–P9 model. It may report success only after P9 reasserts every applicable committed postcondition. Internal phases invoke the CLI by P3's absolute path; shell discovery is checked only at P8.
|
|
||||||
|
|
||||||
## Canonical phases
|
|
||||||
|
|
||||||
| Phase | Responsibility | Failure disposition |
|
|
||||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
||||||
| P0 Resolve context | State target user, HOME, shell, privilege mode, architecture, libc, Node, and npm | Fail before mutation |
|
|
||||||
| P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation |
|
|
||||||
| P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work |
|
|
||||||
| P3 Install CLI | Install at the configured absolute prefix and require exact resolved version | Restore the prior prefix/npmrc snapshot |
|
|
||||||
| P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees |
|
|
||||||
| P5 Identity | Validate SOUL/USER content, owner, and mode; establish any credential capability requested downstream | Restore generated identity/credential binding |
|
|
||||||
| P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files |
|
|
||||||
| P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources |
|
|
||||||
| P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles |
|
|
||||||
| P9 Verify + commit | Re-run P0–P8, commit the manifest, and seal the journal | Leave an honestly reported resumable failure or restore the pre-install snapshot |
|
|
||||||
|
|
||||||
The phase numbers are a cross-workstream contract and must not be renumbered.
|
|
||||||
|
|
||||||
## Side-effect-free check
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash tools/install.sh --check # stable/latest lane
|
|
||||||
bash tools/install.sh --check --next # prerelease lane
|
|
||||||
```
|
|
||||||
|
|
||||||
`--check`:
|
|
||||||
|
|
||||||
- emits exactly one `[P0]` through `[P8]` PASS/FAIL row;
|
|
||||||
- exits non-zero if any predicate fails;
|
|
||||||
- does not create the npm prefix, lock, journal, manifest, shell profile, or runtime file;
|
|
||||||
- uses temporary npm observation storage outside the target HOME and removes it before exit.
|
|
||||||
|
|
||||||
P4 currently fails as `NOT-MEASURED / UNDECLARED` until the installer publishes `~/.config/mosaic/.install-shipped-skills.json`. C1 deliberately does not select among the conflicting candidate populations; C5 owns publishing and fulfilling that declaration. Once present, the P4 predicate requires the declaration's lane/version to match the resolved install and every named skill to remain contained under `skills/<name>/SKILL.md` with matching loadable frontmatter.
|
|
||||||
|
|
||||||
## Durable journal
|
|
||||||
|
|
||||||
Each mutating run creates a private transaction directory:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${XDG_STATE_HOME:-~/.local/state}/mosaic/install/
|
|
||||||
active.json
|
|
||||||
<UTC-run-id>/
|
|
||||||
journal.ndjson
|
|
||||||
journal.ndjson.sha256 # committed runs only
|
|
||||||
commands.log
|
|
||||||
snapshot/
|
|
||||||
```
|
|
||||||
|
|
||||||
Before each mutation scope is touched, `journal.ndjson` records:
|
|
||||||
|
|
||||||
- phase and path;
|
|
||||||
- whether prior state existed and where its snapshot lives;
|
|
||||||
- the reversal action;
|
|
||||||
- the captured command-output location and command status.
|
|
||||||
|
|
||||||
Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass.
|
|
||||||
|
|
||||||
Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state.
|
|
||||||
|
|
||||||
`active.json` is the current projection:
|
|
||||||
|
|
||||||
- `in-progress`: incomplete/open transaction;
|
|
||||||
- `rolled-back`: a fault restored the snapshot;
|
|
||||||
- `rollback-failed`: restoration failed or refused a replaced/unsafe target and requires manual recovery;
|
|
||||||
- `failed-resumable`: named postconditions failed and the recorded partial state remains for remediation;
|
|
||||||
- `committed`: P9 passed and the journal is sealed.
|
|
||||||
|
|
||||||
## Failure recovery
|
|
||||||
|
|
||||||
1. Read the named phase and remediation line from installer stderr.
|
|
||||||
2. Inspect `active.json`, then the referenced `journal.ndjson` and `commands.log`. Command output needed to diagnose a failure is preserved and surfaced; it is not redirected away.
|
|
||||||
3. For `rolled-back`, verify the target paths match their pre-install state before retrying.
|
|
||||||
4. For `failed-resumable`, repair the named phase owner requirement, then run `install.sh --check` before retrying the installer.
|
|
||||||
5. Do not activate the #869 enforcement hooks merely to turn P6 green. A broker-less host with those hooks is a failed P6 state.
|
|
||||||
|
|
||||||
## Greenfield CI gate
|
|
||||||
|
|
||||||
`.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch.
|
|
||||||
|
|
||||||
The C1 fixture intentionally returns an attributable RED while C2–C5 remain open. CI itself remains green only when the fixture's final P0–P9 verdicts, required discriminator rows, and non-zero exit match the versioned contract in `tools/fixtures/greenfield-expected-red.tsv`. Any later remediation that changes an observed verdict makes CI red until the owning lane deliberately updates that manifest:
|
|
||||||
|
|
||||||
- `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success.
|
|
||||||
- `git` absent: P1 fails before target mutation and the installer emits no `Done.`.
|
|
||||||
|
|
||||||
The fixture is lane-parametric:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash tools/e2e-install-test.sh --lane next --git present
|
|
||||||
bash tools/e2e-install-test.sh --lane main --git present
|
|
||||||
```
|
|
||||||
|
|
||||||
CI exercises both lane parameters as expected-RED structural checks. Delivery targets `main` under the trunk-only merge rule; `next` remains a non-merging integration lane. The linked installer issue stays open after merge and closes only after Jarvis independently validates the greenfield behavior.
|
|
||||||
|
|
||||||
## Source trust boundary
|
|
||||||
|
|
||||||
Remote source mode pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. This provides immutable run provenance and archive safety, not an independent authenticity root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout CI seam does verify an expected digest supplied independently by the fixture.
|
|
||||||
@@ -12,20 +12,6 @@ with no snapshot to fall back to.
|
|||||||
Protection is layered. Each layer is independent; a later layer catches what an
|
Protection is layered. Each layer is independent; a later layer catches what an
|
||||||
earlier one misses.
|
earlier one misses.
|
||||||
|
|
||||||
## Layer 0 — Transaction journal (install-wide recovery)
|
|
||||||
|
|
||||||
The unified installer opens a private journal under
|
|
||||||
`${XDG_STATE_HOME:-~/.local/state}/mosaic/install/` before the first target
|
|
||||||
mutation. Every mutation scope records its path, prior snapshot, and reversal
|
|
||||||
instructions before it is touched. Journal write/sync failure is fatal, and P9
|
|
||||||
seals successful journals with a SHA-256 sidecar. See
|
|
||||||
[Installer state machine and recovery](./installer-state-machine.md).
|
|
||||||
|
|
||||||
This transaction journal is distinct from the retained operator-only backup
|
|
||||||
below. The transaction journal is required for correctness and rollback;
|
|
||||||
Layer 2's durable backup remains a separately stated, fail-open recovery bonus
|
|
||||||
for a manifest bug that the normal transaction did not detect.
|
|
||||||
|
|
||||||
## Layer 1 — Manifest-owned sync (prevention)
|
## Layer 1 — Manifest-owned sync (prevention)
|
||||||
|
|
||||||
The single source of truth for ownership is
|
The single source of truth for ownership is
|
||||||
|
|||||||
@@ -175,8 +175,18 @@ Or use the direct URL:
|
|||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
||||||
non-interactive use:
|
|
||||||
|
Install lanes:
|
||||||
|
|
||||||
|
| Lane | Command | Source |
|
||||||
|
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
|
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
|
||||||
|
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
|
||||||
|
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
|
||||||
|
|
||||||
|
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
|
||||||
|
Flags for non-interactive use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
--yes # Accept all defaults
|
--yes # Accept all defaults
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
# CI Queue Guard Purpose Semantics
|
||||||
|
|
||||||
|
- **Issue:** #1146
|
||||||
|
- **Target branch:** `next`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`ci-queue-wait.sh` treats any result other than terminal success as asserted non-readiness. That is correct for merge readiness, but incorrect for the pre-push queue guard: a terminal failure or an empty status set means no pipeline is queued or running, so the queue is clear.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Make final-state handling purpose-sensitive while preserving the existing provider and payload safeguards:
|
||||||
|
|
||||||
|
- `--purpose push`
|
||||||
|
- wait while state is `pending`;
|
||||||
|
- return success for `terminal-success`, `terminal-failure`, and `no-status`;
|
||||||
|
- continue rejecting `malformed`, `unknown`, and unrecognized states.
|
||||||
|
- `--purpose merge`
|
||||||
|
- return success only for `terminal-success`;
|
||||||
|
- continue rejecting `terminal-failure`, `no-status`, malformed, unknown, and unrecognized states.
|
||||||
|
- `--require-status` remains authoritative: `no-status` fails for either purpose when it is supplied.
|
||||||
|
|
||||||
|
Diagnostics will explicitly distinguish a queue-clear push result from successful CI so callers cannot mistake an old failure for a green pipeline.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Extend the process-level tri-state regression harness with separate push and merge assertions:
|
||||||
|
|
||||||
|
1. Push passes for terminal success, terminal failure, and no status.
|
||||||
|
2. Push still fails for pending, malformed, and unknown states.
|
||||||
|
3. `--require-status` makes push/no-status fail.
|
||||||
|
4. Merge behavior remains fail-closed except for terminal success.
|
||||||
|
5. Existing provider-unavailable audit behavior remains unchanged.
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# CI Queue Guard Purpose Semantics Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make the pre-push CI queue guard pass when no pipeline is queued or running while preserving fail-closed merge readiness.
|
||||||
|
|
||||||
|
**Architecture:** Keep provider lookup and tri-state classification unchanged. Make only the final state dispatch purpose-sensitive: push treats valid non-pending states as queue-clear, while merge continues to require terminal success. Preserve `--require-status`, malformed-payload rejection, unknown-state rejection, and audited provider-unavailable behavior.
|
||||||
|
|
||||||
|
**Tech Stack:** Bash, process-level shell regression harnesses, Gitea/GitHub status APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Freeze Purpose-Specific State Semantics
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
|
||||||
|
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
|
||||||
|
|
||||||
|
**Step 1: Add failing push assertions**
|
||||||
|
|
||||||
|
Change push expectations so `terminal-failure` and `no-status` require exit 0 plus an explicit `queue-clear` diagnostic. Add a `--require-status` assertion that keeps push/no-status non-zero.
|
||||||
|
|
||||||
|
**Step 2: Add failing merge assertions**
|
||||||
|
|
||||||
|
Invoke the same harness with `MOSAIC_TEST_PURPOSE=merge` and assert terminal failure and no status remain non-zero while terminal success remains zero.
|
||||||
|
|
||||||
|
**Step 3: Add unknown-state coverage**
|
||||||
|
|
||||||
|
Add a stub payload with a syntactically valid but unsupported status value and assert both purposes reject it.
|
||||||
|
|
||||||
|
**Step 4: Run the focused test and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: failures showing push terminal-failure and no-status returned exit 3 instead of exit 0 or lacked `queue-clear` diagnostics.
|
||||||
|
|
||||||
|
**Step 5: Commit the failing tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
git commit -m "test(ci): define purpose-aware queue readiness"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Implement Purpose-Sensitive Final-State Dispatch
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/mosaic/framework/tools/git/ci-queue-wait.sh:458-481`
|
||||||
|
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh`
|
||||||
|
- Test: `packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh`
|
||||||
|
|
||||||
|
**Step 1: Implement push queue-clear behavior**
|
||||||
|
|
||||||
|
For `no-status`, retain the existing `--require-status` failure. Otherwise, return success for push with an explicit diagnostic such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ci-queue-wait] queue-clear state=no-status purpose=push branch=<branch>; no queued or running CI.
|
||||||
|
```
|
||||||
|
|
||||||
|
For `terminal-failure`, return success only for push with the same queue-clear wording. Merge must continue returning asserted non-readiness.
|
||||||
|
|
||||||
|
**Step 2: Preserve malformed and unknown rejection**
|
||||||
|
|
||||||
|
Keep `malformed`, `unknown`, and unrecognized states non-zero for both purposes.
|
||||||
|
|
||||||
|
**Step 3: Run focused tests and verify GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both scripts exit 0 and report their regression suites passed.
|
||||||
|
|
||||||
|
**Step 4: Commit implementation**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/mosaic/framework/tools/git/ci-queue-wait.sh
|
||||||
|
git commit -m "fix(ci): separate push queue clearance from merge readiness"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Verify, Review, and Document Evidence
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `docs/scratchpads/1146-ci-queue-purpose.md`
|
||||||
|
|
||||||
|
**Step 1: Run shell syntax and focused regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -n packages/mosaic/framework/tools/git/ci-queue-wait.sh
|
||||||
|
bash -n packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run repository quality gates**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm preflight
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: every command exits 0.
|
||||||
|
|
||||||
|
**Step 3: Obtain independent review**
|
||||||
|
|
||||||
|
Request review of the exact branch head. Remediate all blocking findings and rerun focused and baseline gates.
|
||||||
|
|
||||||
|
**Step 4: Record evidence and commit**
|
||||||
|
|
||||||
|
Update the scratchpad with test output, review result, and residual risk, then commit it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/scratchpads/1146-ci-queue-purpose.md
|
||||||
|
git commit -m "docs(ci): record queue guard verification"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Keep the Merge Wrapper Aligned with the `next` Lane
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/mosaic/framework/tools/git/pr-merge.sh:97-101`
|
||||||
|
- Test: `packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh`
|
||||||
|
|
||||||
|
**Step 1: Write the failing regression**
|
||||||
|
|
||||||
|
Run the exact-head merge regression with its Gitea fixture targeting `next` and confirm the current wrapper rejects it because it only permits `main`.
|
||||||
|
|
||||||
|
**Step 2: Allow only documented integration targets**
|
||||||
|
|
||||||
|
Permit `main` and `next`; reject every other target. Do not alter exact-head pinning, queue-guard invocation, provider selection, or merge method enforcement.
|
||||||
|
|
||||||
|
**Step 3: Run focused merge regressions**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh
|
||||||
|
bash packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass, including a Gitea merge fixture targeting `next`.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/test-pr-merge-head-pin.sh
|
||||||
|
git commit -m "fix(ci): allow reviewed merges into next"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Activate and Deliver Through `next`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Installed output: `~/.config/mosaic/tools/git/ci-queue-wait.sh`
|
||||||
|
|
||||||
|
**Step 1: Activate through the canonical installer**
|
||||||
|
|
||||||
|
From the reviewed worktree, run the framework installer in sync-only keep mode so operator files remain protected:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_SKIP_SKILLS_SYNC=1 \
|
||||||
|
bash packages/mosaic/framework/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify installed/source parity**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmp -s \
|
||||||
|
packages/mosaic/framework/tools/git/ci-queue-wait.sh \
|
||||||
|
~/.config/mosaic/tools/git/ci-queue-wait.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
**Step 3: Run mandatory pre-push queue guard**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B fix/1146-ci-queue-purpose
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: branch-absent or queue-clear success.
|
||||||
|
|
||||||
|
**Step 4: Push and open a PR against `next`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin fix/1146-ci-queue-purpose
|
||||||
|
~/.config/mosaic/tools/git/pr-create.sh \
|
||||||
|
-t "fix(ci): make queue guard purpose-sensitive" \
|
||||||
|
-b "Closes #1146" \
|
||||||
|
-B next \
|
||||||
|
-H fix/1146-ci-queue-purpose \
|
||||||
|
-i 1146
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Complete reviewed integration**
|
||||||
|
|
||||||
|
Wait for exact-head terminal-green CI, obtain the required review, merge via the Mosaic wrapper, verify merged CI, and close #1146. Do not bypass any gate.
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# #1050 — Installer P0–P9 state machine and red-first fixture
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–P9 installer spine, a side-effect-free P0–P8 `--check`, and a lane-parametric Debian/glibc non-root from-zero fixture. The acceptance milestone is an attributable RED on the pre-C1 installer while preserving P3 PASS.
|
|
||||||
|
|
||||||
## Authority and scope
|
|
||||||
|
|
||||||
- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies.
|
|
||||||
- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`).
|
|
||||||
- Historical implementation base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. Delivery PR #1054 targets `main` under L0's trunk-only rule; `next` remains a non-merging integration lane.
|
|
||||||
- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion.
|
|
||||||
- `docs/TASKS.md` is orchestrator-single-writer and is not modified by this worker.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Pre-register the canonical phase/output/side-effect-free/fault-injection checks and observe RED against the base installer.
|
|
||||||
2. Commit the immutable red-first acceptance fixture before implementation.
|
|
||||||
3. Add the state-machine/journal/postcondition spine without repairing P4/P5/P8 symptoms.
|
|
||||||
4. Wire the expected-RED from-zero fixture into Woodpecker using Debian/glibc and a non-root target user.
|
|
||||||
5. Run shell/static baselines, situational container validation, code review, security review, then deliver through a PR to `next` under the coordinator-owned merge path.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
- Working estimate: 32K reasoning/output tokens.
|
|
||||||
- Hard external cap: none stated.
|
|
||||||
- Adaptation: keep implementation in shell surfaces already in scope; no package dependency install unless repository gates require it.
|
|
||||||
|
|
||||||
## Pre-registered acceptance checks
|
|
||||||
|
|
||||||
| ID | Exact case | Expected pre-fix result |
|
|
||||||
|---|---|---|
|
|
||||||
| C1-R1 | `tools/e2e-install-test.sh --lane next` in a clean Debian 12 container as uid 1001 | non-zero; P3 PASS; P4 `NOT-MEASURED / UNDECLARED`; P5/P6/P8 FAIL with own reasons |
|
|
||||||
| C1-R2 | `tools/install-state-machine.test.sh` phase table case | RED because base installer does not enumerate canonical P0–P9 contracts |
|
|
||||||
| C1-R3 | side-effect-free `--check` case over a fingerprinted HOME | RED because base `--check` is version-only rather than P0–P8 predicates |
|
|
||||||
| C1-R4 | fault injection after each P2…P8 | RED because base installer has no injectable durable journal/rollback state |
|
|
||||||
| C1-R5 | Docker unavailable | base harness incorrectly exits 0; replacement must fail non-zero |
|
|
||||||
| C1-R6 | lane resolution | bare checkout is forbidden; fixture must pass `--next` and assert the resolved prerelease version |
|
|
||||||
| C1-R7 | same Debian fixture with `git` absent vs present | absent: P1 FAIL while legacy installer exits 0 and sync degrades; present: P1 PASS and observed store/runtime containment 101/101 |
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- [x] Charter, doctrine, delivery/CI/QA/docs guides read and re-anchored after compaction.
|
|
||||||
- [x] Canonical PRD v2/v3 addenda and charters read from fetched `origin/main`; numbering reconciles with the TL spec. No numbering conflict found. INV-B/C/D are binding and implemented without renumbering.
|
|
||||||
- [x] Target base reachability verified with `merge-base --is-ancestor`.
|
|
||||||
- [x] Issue #1050 created and provider author read back.
|
|
||||||
- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it.
|
|
||||||
- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it.
|
|
||||||
- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here.
|
|
||||||
- [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS.
|
|
||||||
- [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery.
|
|
||||||
- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. CI compares its exact final phase map/reasons to `tools/fixtures/greenfield-expected-red.tsv`; the fixture remains red while the detector job is green only on an exact match.
|
|
||||||
- [ ] Reviews complete. Automated review defects around Bash conditional errexit, explicit exits, P4/P6 persisted action status, dev/offline source resolution, stale locks, checkout coverage, and rollback path safety were remediated. Remaining automated objections are the charter-mandated expected RED/C5 boundary and signed provenance, which the canonical PRD explicitly defers; independent informed review is still required.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`.
|
|
||||||
- `next` is a non-merging integration lane; PR #1054 targets `main`. The old “pending promotion to main” caution dissolved when the base moved. #1050 remains open after merge and closes only after Jarvis validates the greenfield behavior.
|
|
||||||
- #869 must remain staged and inactive.
|
|
||||||
- Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5.
|
|
||||||
|
|
||||||
## Verification log
|
|
||||||
|
|
||||||
- `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes.
|
|
||||||
- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, good/bad discrimination, persisted P4/P6 action failures, P2–P8 rollback, unsafe/overlapping/symlink roots, stale `active.json`, and fatal journal initialization.
|
|
||||||
- `bash tools/install-next-lane.test.sh` passes, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, and prerelease suffix mismatch.
|
|
||||||
- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. `tools/verify-greenfield-expected-red.sh` converts that expected detector result into a green CI assertion and fails on any unreviewed verdict drift.
|
|
||||||
- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test:installer`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full rerun is required after final edits.
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# #1146 — CI Queue Guard Purpose Semantics
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make the pre-push queue guard wait for queued/running CI without requiring the previous remote head to have successful CI. Preserve fail-closed merge readiness.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `packages/mosaic/framework/tools/git/ci-queue-wait.sh`
|
||||||
|
- focused queue-guard regression tests
|
||||||
|
- design and scratchpad documentation
|
||||||
|
- local framework activation required before the fixed guard can authorize this branch's push
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Freeze purpose-specific behavior in failing process-level tests.
|
||||||
|
2. Implement the smallest state-dispatch change.
|
||||||
|
3. Run focused shell tests and repository quality gates.
|
||||||
|
4. Obtain independent review and remediate findings.
|
||||||
|
5. Install the reviewed framework source locally, run the mandatory pre-push queue guard, and push.
|
||||||
|
6. Open a PR against `next`, verify terminal-green CI, and close #1146 after merge.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- ASSUMPTION: no explicit token cap was provided.
|
||||||
|
- Working estimate: 12K tokens.
|
||||||
|
- Scope reduction: change only final-state dispatch and focused tests; do not redesign provider adapters.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- Confirmed source and installed guards are byte-identical.
|
||||||
|
- Reproduced `terminal-failure` blocking `--purpose push`.
|
||||||
|
- Root cause: final-state dispatch requires terminal success for both push and merge.
|
||||||
|
- Design approved: push is queue-clear on valid non-pending states; merge remains fail-closed.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- RED confirmed before implementation: the focused tri-state harness reported push `terminal-failure` and `no-status` as `ASSERTED_NOT_READY`.
|
||||||
|
- GREEN: `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` — all outcome classes passed.
|
||||||
|
- GREEN: `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-github-checks.sh` — 6/6 purpose-aware cases passed.
|
||||||
|
- GREEN: `bash -n` passed for the changed guard and both focused harnesses.
|
||||||
|
- GREEN: `pnpm preflight`, `pnpm typecheck`, and `pnpm lint` passed.
|
||||||
|
- `pnpm test` ran 45/46 workspace test tasks successfully, but the pre-existing Gateway `cross-user-isolation.test.ts` failed during cleanup with PostgreSQL error `28P01` (local `mosaic` password authentication failure). The changed Mosaic framework test task passed within that run.
|
||||||
|
- GREEN: focused queue and merge shell regressions passed after the wrapper change.
|
||||||
|
- GREEN: isolated Mosaic Vitest run passed (81 files, 1,514 tests).
|
||||||
|
- The normal parallel Mosaic Vitest run has an environment-sensitive pre-existing failure in `install-ordering-guard.spec.ts`: the real activation probe changes between two calls while other suites run concurrently. Running the same spec alone and the complete Vitest suite with one fork passes.
|
||||||
|
- The framework shell suite's pre-existing `version_coupling_unittest.py` also fails locally because the newly installed `mosaic` is now on PATH despite the test injecting a nonexistent PATH; CI's clean image does not have this global CLI. All changed queue/merge harnesses pass.
|
||||||
|
- GREEN: `pnpm format:check` passed.
|
||||||
|
- Note: an additional ad hoc Prettier command was not applicable to shell files because Prettier has no shell parser; the repository-wide format check passed using its configured file globs.
|
||||||
|
|
||||||
|
## Review
|
||||||
|
|
||||||
|
- Independent Codex review of the six-file diff: approved, confidence 0.84, zero blockers/should-fix/suggestions.
|
||||||
|
- Review confirmed push queue-clear behavior, merge fail-closed behavior, and `--require-status` coverage.
|
||||||
|
|
||||||
|
## Risks and Blockers
|
||||||
|
|
||||||
|
- Canonical framework activation completed with `MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_SKIP_SKILLS_SYNC=1 bash packages/mosaic/framework/install.sh`.
|
||||||
|
- Source and installed queue guards are byte-identical (`cmp` and SHA-256 parity passed).
|
||||||
|
- The installed pre-push guard now passes for the not-yet-remote feature branch with `queue clear`.
|
||||||
|
- The required merge wrapper then exposed a second bootstrap defect: `pr-merge.sh` hardcoded `main`, contradicting the documented PR-based `next` integration lane. Tracked as #1149 and fixed in the same delivery branch with a regression fixture targeting `next`.
|
||||||
|
- Activation emitted the existing manifest-safety warning that six `fleet/run/*.hb*` operator files were touched then restored; no data loss was observed, but this remains a pre-existing framework-manifest defect to report separately.
|
||||||
|
- The first activation attempt timed out after 600 seconds while copying the 113K-file operator snapshot; the bounded 1,800-second retry completed successfully. It left a partial durable snapshot from the interrupted attempt in the normal backup directory; the completed snapshot is the newer `pre-update-20260810T195317Z` entry.
|
||||||
|
- Full baseline test completion is blocked by the unrelated local PostgreSQL authentication/cleanup failure described above; CI has its own disposable PostgreSQL service.
|
||||||
|
- Existing `.mosaic/orchestrator/*` working-tree changes are unrelated and must remain unstaged.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Scratchpad — FED-M3-06 get verb
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
|
||||||
|
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
|
||||||
|
- Unit coverage for controller pipeline + query service RBAC guardrails
|
||||||
|
- Register controller/service in `FederationModule`
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Mirror the list verb pipeline: `FederationAuthGuard` → `FederationScopeService` → read-only query service.
|
||||||
|
2. Return one `_source: "local"` tagged item on success.
|
||||||
|
3. Return federation error envelopes:
|
||||||
|
- `404 not_found` when the resource id does not exist.
|
||||||
|
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
|
||||||
|
- `400 invalid_request` for malformed ids/scope requests.
|
||||||
|
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
|
||||||
|
- Review-change coverage added for comment 15971:
|
||||||
|
- get note access now requires subject ownership AND authorized mission intersection.
|
||||||
|
- missing federation context returns structured `401 unauthorized` envelope.
|
||||||
|
- unsupported get resources fail closed with structured denial.
|
||||||
|
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
|
||||||
|
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
|
||||||
|
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
|
||||||
|
- `pnpm build` — pass (23 successful tasks before review changes).
|
||||||
|
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
|
||||||
|
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
|
||||||
|
- `pnpm format:check` — pass (re-run after review changes).
|
||||||
|
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user