Compare commits

..
Author SHA1 Message Date
jason.woltje 20718b5a27 Merge pull request 'P4-1: read-only Projects + Tasks SPA pages + dev-port pins' (#1153) from feat/webui-p4-1 into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 04:00:36 +00:00
shaggy (mosaic-dev box) 29db24210c fix(web): P4-1 restore graceful degradation for missions/tasks fetch on project detail
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 22:43:47 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 a6085eea37 feat(web): add read-only project and task SPA pages
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-10 22:23:59 -05:00
Mos e00cc475a2 Merge pull request 'P3 — Typed SPA chat (Phase P webUI)' (#1151) from feat/webui-p3-chat into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 22:57:07 +00:00
shaggy (mosaic-dev box) 7d84e4ee03 fix(gateway): sanitize raw exceptions in /mcp status + /reload sources (P3 re-review#5 blocker)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 16:58:07 -05:00
shaggy (mosaic-dev box) 4aaf41dd1a fix(web,gateway): close P3 re-review#4 findings — sanitize all executor catches; lock pre-start turn boundary (wire turnId deferred) 2026-08-10 16:07:12 -05:00
mos-dt-0 bf32f29acd fix(ci): make queue guard purpose-sensitive (#1148)
ci/woodpecker/push/publish Pipeline failed
2026-08-10 20:54:09 +00:00
Jason Woltje 1655b1579a Move deploy and briefs into docs folder. Remove old files.
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:44:19 -05:00
Jason Woltje e478a359eb Move briefs directory inside the docs
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:42:04 -05:00
Jason Woltje 76e4242cb1 Reformat Requirements block
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:40:18 -05:00
Jason Woltje 00eb216480 docs(agents): consolidate project guidance 2026-08-10 14:27:59 -05:00
shaggy (mosaic-dev box) d46a2d675a fix(web,gateway): close P3 re-review#3 findings — sanitize command errors, harden turn-lock & caps 2026-08-10 14:21:52 -05:00
shaggy 8c27024d0e Merge pull request 'fix(gateway): gate FederationModule on tier === 'federated' (#1138)' (#1140) from fix/1138-conditional-federation into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 07:09:51 +00:00
shaggy (mosaic-dev box) 48bb19310d fix(web): close P3 chat re-review findings 2026-08-10 01:58:27 -05:00
shaggy (mosaic-dev box) 406e40584d test(gateway): raise module-graph import timeout for CI load robustness (#1138)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 01:34:24 -05:00
shaggy (mosaic-dev box) 677aeb0c93 fix(gateway): restore daemon config discovery (#1138)
ci/woodpecker/pr/ci Pipeline failed
2026-08-10 00:31:58 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 caebf9ef70 fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-10 00:24:20 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 bd0ef2ab25 fix(gateway): anchor remaining config loads (#1138)
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-09 23:54:53 -05:00
shaggy (mosaic-dev box) 2d5a8c81ec fix(gateway): anchor config discovery and isolate env tests (#1138) 2026-08-09 23:11:09 -05:00
shaggy (mosaic-dev box) 884d527cc8 fix(gateway): anchor dotenv discovery to module (#1138) 2026-08-09 22:21:17 -05:00
shaggy (mosaic-dev box) b2e005f2b4 feat(web): add typed SPA chat
Bring the chat experience into the Vite/React-Router SPA on the exact typed
Socket.IO /chat contract from @mosaicstack/types, replacing the /chat
placeholder behind AuthGuard. Surfaces message:ack (with an accessible
status), agent:start, streamed agent:text/agent:thinking, tool start/end
status, agent:end with usage, session:info (thinking controls + routing
decision), commands:manifest, command:result, command:approval (with a
one-time approved-run affordance), system:reload (refreshing the rendered
manifest), and error, and emits message/abort/set:thinking/command:execute/
command:approve with exact payloads.

The gateway does not guarantee message:ack is the first event for a new
conversation (session:info, and error on auth/session-creation failure, can
both arrive first) — conversation-scoped events now adopt the conversation
from whichever scoped event names it first while a send is pending, then
filter everything else against that established conversation. A typed error
stops streaming instead of leaving Stop stuck active; agent:end no longer
appends an empty assistant turn when there is no text or thinking; and a
second message can no longer be sent while a turn is streaming.

Command approval is now integrity-checked end to end: only one
command:approve request may be outstanding at a time (a concurrent request
is ignored rather than overwriting the pending command/args), a stale or
mismatched command:approval response cannot replace active approval state,
and running an approved command clears its approval state immediately (via
a ref, before React re-renders) so a double-click cannot replay
command:execute.

The `/chat` socket is now typed at a single boundary: apps/web/src/lib/
socket.ts narrows socket.io-client's untyped `io()` return value to
`ChatSocket` (Socket<ServerToClientEvents, ClientToServerEvents>) once, at
creation, via the one assertion the library's types force; every consumer
(use-chat-connection.ts) then gets fully checked `on`/`emit` calls with no
further casts. The shared contract types live in the new
apps/web/src/lib/chat-contract.ts (replacing the old spa/chat/types.ts
shim), which re-exports them via type-only imports resolved directly
against packages/types/src (apps/web has no @mosaicstack/types package
dependency, so this stays source-only and is erased at compile time —
no package manifest or lockfile is touched). The two recorded-event test
suites now drive a shared, typed fake socket
(spa/chat/test-support/fake-chat-socket.ts) instead of an untyped
`(event: string, payload: unknown)` harness, so a wrong event name or
malformed payload fails to compile.
2026-08-09 21:39:16 -05:00
shaggy 87daa12976 Merge pull request 'P2 — web SPA data layer + same-origin auth' (#1144) from feat/webui-p2-data-auth into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 01:52:01 +00:00
shaggy (mosaic-dev box) b82a51da80 fix(gateway): load dotenv before federation tier gate (#1138) 2026-08-09 20:50:37 -05:00
shaggy (mosaic-dev box) 90cf286a09 fix(web): reject protocol-relative auth callbacks
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 20:43:27 -05:00
shaggy (mosaic-dev box) 0aef432052 docs(scratchpad): record P2 remediation evidence 2026-08-09 20:21:53 -05:00
shaggy 41a16cc916 Merge pull request 'fix(docker): gateway image — git in runner, MOSAIC_ROOT workspace dir, scripts/ in builder, EXPOSE 14242' (#1142) from fix/gateway-runner-image into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 01:21:30 +00:00
shaggy (mosaic-dev box) e16c08aa9f test(web): align jsdom abort signals with Node 2026-08-09 20:20:30 -05:00
shaggy (mosaic-dev box) a34e92cf39 fix(gateway): harden workspace repository cloning
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 20:12:39 -05:00
shaggy (mosaic-dev box) a4861c221f docs(scratchpad): record WebUI P2 verification 2026-08-09 20:02:22 -05:00
shaggy (mosaic-dev box) 46d68e1ff4 feat(web): add same-origin SPA authentication 2026-08-09 20:00:22 -05:00
shaggy c3496334a5 Merge pull request 'feat(web): P1 — Vite + React Router skeleton beside Next (Phase P RFC, increment 1/6)' (#1143) from feat/webui-p1-vite-skeleton into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 00:51:02 +00:00
shaggy 6f29d00149 Merge pull request 'fix: break-C — install-hooks no-ops without git; web image builds @mosaicstack/web' (#1141) from fix/break-c-hooks-and-web-image into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 00:50:19 +00:00
shaggy (mosaic-dev box)andClaude Fable 5 068d0f9b1c feat(web): P1 Vite skeleton beside Next — entry, router, guards, vitest 3
ci/woodpecker/pr/ci Pipeline was successful
First increment of the approved Phase P RFC (webui-mission). Adds a Vite + React
Router SPA scaffold coexisting with the Next app: index.html with the theme
anti-flash script, src/main.tsx entry, the v1 parity route table under Guest/Auth
guard shells, and a dev proxy (/api, /socket.io ws) to the gateway on 14242 so the
SPA is same-origin in dev. vitest bumped to v3 (vite 8 pairing); existing specs
pass unchanged. Next remains the served app until the P5 cutover.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 19:02:55 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 13cd673d50 fix(docker): gateway runner needs git + MOSAIC_ROOT workspace dir; EXPOSE actual port 14242
ci/woodpecker/pr/ci Pipeline was successful
WorkspaceService shells out to git at runtime and roots workspaces at
$MOSAIC_ROOT/.workspaces — the runner image had no git binary and no
workspace directory. EXPOSE said 4000 but main.ts defaults to 14242.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:42 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 620cc608e0 fix(docker): copy scripts/ into web builder — prepare runs install-hooks.mjs on install
ci/woodpecker/pr/ci Pipeline was successful
The layer-cached install copies only manifests and packages/, so the
root prepare script could not be found and pnpm install exited 1
before the git-absent guard could even run.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:18 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 91e692e3e7 fix: break-C — install-hooks no-ops without git; web image builds @mosaicstack/web
ci/woodpecker/pr/ci Pipeline was canceled
install-hooks.mjs hard-failed (exit 1) in environments without a git
binary — e.g. the docker image builds, which have no git and no repo.
Hook installation is meaningless there; skip with a warning instead.

docker/web.Dockerfile filtered @mosaic/web, but the package is named
@mosaicstack/web, so the image build compiled nothing.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 17:58:13 -05:00
shaggy (mosaic-dev box) b4753a75cd fix(gateway): gate FederationModule on tier federated (#1138)
ci/woodpecker/pr/ci Pipeline was successful
CaService hard-requires STEP_CA_URL/provisioner config at construction, so an
unconditional FederationModule import makes every standalone/local boot die at
DI time. Gate the module on loadConfig().tier === federated, matching the
documented intent of the federation compose profile (must not start in
non-federated dev).

Verified in mosaic-dev box: standalone tier boots to "Gateway listening on
port 14242" with bootstrap/socket.io/auth surfaces responding; federated tier
path unchanged.
2026-08-09 17:26:28 -05:00
velmaandmos-dt-0 24bbd40dc7 docs: WebUI fleet Claude bridge — Task 0 decision plan (#1131)
ci/woodpecker/push/publish Pipeline failed
Docs-only plan PR. FRED_APPROVED_REF=0629361ca39a4dd7fb3e575d11c64bea9e545dae (review 147). Merged by fred (orchestrator) via API: pr-merge.sh policy predates the next lane (main-only hardcode) — wrapper fix tracked separately.

Co-authored-by: Velma <[email protected]>
2026-08-09 10:28:41 +00:00
91 changed files with 10147 additions and 372 deletions
+62 -23
View File
@@ -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)
+3 -44
View File
@@ -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
+5 -1
View File
@@ -50,7 +50,11 @@ mosaic wizard # Full guided setup (gateway install → verify)
- Node.js ≥ 20 - Node.js ≥ 20
- npm (for global @mosaicstack/mosaic install) - npm (for global @mosaicstack/mosaic install)
- 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) - One or more runtimes:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://opencode.ai)
- [Pi](https://pi.dev)
## Usage ## Usage
+624
View File
@@ -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,
);
});
+11 -1
View File
@@ -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,7 +74,14 @@ const mockChatGateway = {
broadcastSessionInfo: vi.fn(), broadcastSessionInfo: vi.fn(),
}; };
function buildService(redis: typeof mockRedis | null = mockRedis): 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,
@@ -82,7 +91,7 @@ function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecut
mockBrain as never, mockBrain as never,
null, null,
mockChatGateway as never, mockChatGateway as never,
null, mcpClient as never,
); );
} }
@@ -258,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();
});
}); });
@@ -159,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.',
};
} }
} }
@@ -336,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,
}; };
} }
@@ -391,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,
}; };
} }
@@ -608,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.`,
}; };
} }
} }
+2 -1
View File
@@ -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],
+133
View File
@@ -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();
+164
View File
@@ -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,
);
});
+3 -15
View File
@@ -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}`);
} }
@@ -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();
});
});
+4 -2
View File
@@ -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
+30
View File
@@ -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>
+9 -3
View File
@@ -4,21 +4,25 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "node ../../scripts/build-web.mjs", "build": "node ../../scripts/build-web.mjs",
"dev": "next dev", "build:vite": "vite build",
"dev": "next dev -p 3101",
"dev:vite": "vite",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"start": "next start" "start": "next start -p 3101"
}, },
"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"
+57
View File
@@ -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 -3
View File
@@ -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,
+29
View File
@@ -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');
});
});
+2 -1
View File
@@ -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()],
}); });
+30
View File
@@ -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');
});
});
+23
View File
@@ -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;
}
}
+61
View File
@@ -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>;
+98
View File
@@ -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
View File
@@ -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;
} }
-48
View File
@@ -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();
});
});
-53
View File
@@ -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';
}
}
+19
View File
@@ -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>,
);
+67
View File
@@ -0,0 +1,67 @@
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 { ProjectDetailPage } from '@/spa/pages/project-detail';
import { ProjectsPage } from '@/spa/pages/projects';
import {
ProjectDetailRouteErrorBoundary,
ProjectsRouteErrorBoundary,
TasksRouteErrorBoundary,
} from '@/spa/pages/resource-route-error-boundaries';
import { TasksPage } from '@/spa/pages/tasks';
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: <ProjectsPage />,
errorElement: <ProjectsRouteErrorBoundary />,
},
{
path: '/projects/:id',
element: <ProjectDetailPage />,
errorElement: <ProjectDetailRouteErrorBoundary />,
},
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
{ 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();
});
});
+164
View File
@@ -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>
);
}
+98
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+49
View File
@@ -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;
}
+68
View File
@@ -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();
});
});
+24
View File
@@ -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
+135
View File
@@ -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');
});
});
+23
View File
@@ -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>
);
}
+636
View File
@@ -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();
});
});
+92
View File
@@ -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>
);
}
+152
View File
@@ -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&apos;t have an account?{' '}
<Link to="/register" className="text-blue-400 hover:text-blue-300">
Sign up
</Link>
</p>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
+115
View File
@@ -0,0 +1,115 @@
import type { Mission, Project, Task } from '@/lib/types';
export const projectFixtures: Project[] = [
{
id: 'project-1',
name: 'Mosaic Stack',
description: 'Gateway and dashboard parity work',
status: 'active',
userId: 'user-1',
metadata: {
prd: '# Mosaic Stack PRD\n\n## Objective\n\nShip the SPA route parity pages.',
},
createdAt: '2026-08-01T12:00:00.000Z',
updatedAt: '2026-08-09T18:30:00.000Z',
},
{
id: 'project-2',
name: 'Agent Runtime',
description: 'Pi SDK integration',
status: 'paused',
userId: 'user-1',
metadata: null,
createdAt: '2026-08-02T08:00:00.000Z',
updatedAt: '2026-08-05T10:00:00.000Z',
},
];
export const missionFixtures: Mission[] = [
{
id: 'mission-1',
name: 'Ship web parity',
description: 'Port read-only routes into the SPA',
status: 'active',
projectId: 'project-1',
metadata: null,
createdAt: '2026-08-03T12:00:00.000Z',
updatedAt: '2026-08-09T12:00:00.000Z',
},
{
id: 'mission-2',
name: 'Unrelated mission',
description: 'Must be filtered out of the project detail view',
status: 'planning',
projectId: 'project-2',
metadata: null,
createdAt: '2026-08-04T12:00:00.000Z',
updatedAt: '2026-08-04T12:00:00.000Z',
},
];
export const taskFixtures: Task[] = [
{
id: 'task-1',
title: 'Route /projects',
description: 'Port the read-only projects listing into the SPA',
status: 'done',
priority: 'high',
projectId: 'project-1',
missionId: 'mission-1',
assignee: 'Jarvis',
tags: ['spa', 'projects'],
dueDate: '2026-08-12T00:00:00.000Z',
metadata: {
notes: 'Read-only modal content should remain intact.',
pr_links: [{ url: 'https://example.invalid/pr/1', label: 'PR #1' }],
},
createdAt: '2026-08-04T10:00:00.000Z',
updatedAt: '2026-08-09T15:00:00.000Z',
},
{
id: 'task-2',
title: 'Route /projects/:id',
description: 'Reuse overview, tasks, missions, and PRD tabs',
status: 'in-progress',
priority: 'critical',
projectId: 'project-1',
missionId: 'mission-1',
assignee: null,
tags: ['spa', 'detail'],
dueDate: null,
metadata: null,
createdAt: '2026-08-05T09:00:00.000Z',
updatedAt: '2026-08-10T08:00:00.000Z',
},
{
id: 'task-3',
title: 'Route /tasks',
description: 'Wire list and kanban modal interactions',
status: 'blocked',
priority: 'medium',
projectId: 'project-1',
missionId: null,
assignee: null,
tags: ['spa', 'tasks'],
dueDate: null,
metadata: null,
createdAt: '2026-08-06T09:00:00.000Z',
updatedAt: '2026-08-08T08:00:00.000Z',
},
{
id: 'task-4',
title: 'Other project task',
description: 'Used only to confirm mission filtering remains project-scoped',
status: 'not-started',
priority: 'low',
projectId: 'project-2',
missionId: 'mission-2',
assignee: null,
tags: null,
dueDate: null,
metadata: null,
createdAt: '2026-08-06T09:00:00.000Z',
updatedAt: '2026-08-06T09:00:00.000Z',
},
];
@@ -0,0 +1,174 @@
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';
import { missionFixtures, projectFixtures, taskFixtures } from './page-fixtures';
const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
api: apiMock,
}));
import { ProjectDetailPage } from './project-detail';
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
apiMock.mockReset();
});
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
const routes: RouteObject[] = [
{ path: '/projects', element: <p>Projects index target</p> },
{ path: '/projects/:id', element: <ProjectDetailPage /> },
];
const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
return router;
}
function clickButtonByText(text: string): void {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
describe('ProjectDetailPage', () => {
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage();
expect(apiMock.mock.calls).toEqual([
['/api/projects/project-1'],
['/api/missions'],
['/api/tasks?projectId=project-1'],
]);
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Route /projects/:id');
expect(container.textContent).toContain('Tasks');
expect(container.textContent).toContain('Done');
expect(container.textContent).toContain('Blocked');
await act(async () => {
clickButtonByText('Missions (1)');
});
expect(container.textContent).toContain('Ship web parity');
expect(container.textContent).not.toContain('Unrelated mission');
await act(async () => {
clickButtonByText('PRD');
});
expect(container.textContent).toContain('Mosaic Stack PRD');
expect(container.textContent).toContain('Ship the SPA route parity pages.');
});
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage();
await act(async () => {
clickButtonByText('Tasks (3)');
});
const row = [...container.querySelectorAll('tr')].find((candidate) =>
candidate.textContent?.includes('Route /projects'),
);
expect(row).toBeTruthy();
await act(async () => {
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
expect(container.textContent).toContain('Read-only modal content should remain intact.');
const closeButton = container.querySelector('button[aria-label="Close task details"]');
expect(closeButton).toBeTruthy();
await act(async () => {
closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it('renders the project with an empty missions tab when the missions request fails', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockRejectedValueOnce(new Error('Missions request failed'))
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
await renderProjectDetailPage();
expect(container.textContent).toContain('Mosaic Stack');
expect(container.querySelector('[role="alert"]')).toBeNull();
await act(async () => {
clickButtonByText('Missions (0)');
});
expect(container.textContent).toContain('No missions for this project');
});
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
apiMock
.mockRejectedValueOnce(new Error('Project request failed'))
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
const router = await renderProjectDetailPage();
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Project request failed');
expect(container.textContent).not.toContain('Mosaic Stack');
await act(async () => {
clickButtonByText('Back to projects');
});
expect(router.state.location.pathname).toBe('/projects');
});
});
+344
View File
@@ -0,0 +1,344 @@
import { useEffect, useState, type ReactElement } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { MissionTimeline } from '@/components/projects/mission-timeline';
import { PrdViewer } from '@/components/projects/prd-viewer';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
import { getErrorMessage } from './page-errors';
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
const projectStatusColors: Record<string, string> = {
active: 'bg-success/20 text-success',
paused: 'bg-warning/20 text-warning',
completed: 'bg-blue-600/20 text-blue-400',
archived: 'bg-gray-600/20 text-gray-400',
};
const taskStatusColors: Record<string, string> = {
'not-started': 'bg-gray-600/20 text-gray-300',
'in-progress': 'bg-blue-600/20 text-blue-400',
blocked: 'bg-error/20 text-error',
done: 'bg-success/20 text-success',
cancelled: 'bg-gray-600/20 text-gray-500',
};
interface TabButtonProps {
id: Tab;
label: string;
activeTab: Tab;
onClick: (tab: Tab) => void;
}
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElement {
return (
<button
type="button"
onClick={() => onClick(id)}
className={cn(
'border-b-2 px-4 py-2 text-sm transition-colors',
activeTab === id
? 'border-text-primary text-text-primary'
: 'border-transparent text-text-muted hover:text-text-secondary',
)}
>
{label}
</button>
);
}
export function ProjectDetailPage(): ReactElement {
const { id = '' } = useParams();
const navigate = useNavigate();
const [project, setProject] = useState<Project | null>(null);
const [missions, setMissions] = useState<Mission[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
useEffect(() => {
if (!id) {
setError('Project id is missing.');
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
void Promise.all([
api<Project>('/api/projects/' + id),
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
])
.then(([loadedProject, allMissions, loadedTasks]) => {
if (cancelled) return;
setProject(loadedProject);
setMissions(allMissions.filter((mission) => mission.projectId === id));
setTasks(loadedTasks);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load project.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [id]);
if (loading) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
</div>
);
}
if (error || !project) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
{error ?? 'Project not found.'}
</div>
<button
type="button"
onClick={() => navigate('/projects')}
className="mt-4 w-fit text-sm underline"
>
Back to projects
</button>
</div>
);
}
const filteredTasks =
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
const prdContent = getPrdContent(project);
const tabs: Array<{ id: Tab; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'tasks', label: `Tasks (${tasks.length})` },
{ id: 'missions', label: `Missions (${missions.length})` },
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
];
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<button
type="button"
onClick={() => navigate('/projects')}
className="hover:text-text-secondary"
>
Projects
</button>
<span>/</span>
<span className="text-text-primary">{project.name}</span>
</nav>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{project.status}
</span>
</div>
{project.description ? (
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
) : null}
<p className="mt-2 text-xs text-text-muted">
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.updatedAt).toLocaleDateString()}
</p>
</div>
</div>
</header>
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Tasks" value={String(tasks.length)} />
<StatCard
label="Done"
value={String(tasks.filter((task) => task.status === 'done').length)}
valueClass="text-success"
/>
<StatCard
label="In Progress"
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
valueClass="text-blue-400"
/>
<StatCard
label="Blocked"
value={String(tasks.filter((task) => task.status === 'blocked').length)}
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
/>
</div>
<div className="mb-6 flex gap-0 border-b border-surface-border">
{tabs.map((tab) => (
<TabButton
key={tab.id}
id={tab.id}
label={tab.label}
activeTab={activeTab}
onClick={setActiveTab}
/>
))}
</div>
{activeTab === 'overview' ? (
<OverviewTab project={project} missions={missions} tasks={tasks} />
) : null}
{activeTab === 'tasks' ? (
<div>
<div className="mb-4">
<TaskStatusSummary
tasks={tasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
/>
</div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
</div>
) : null}
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
{activeTab === 'prd' && prdContent ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
<PrdViewer content={prdContent} />
</div>
) : null}
{selectedTask ? (
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
) : null}
</div>
);
}
function OverviewTab({
project,
missions,
tasks,
}: {
project: Project;
missions: Mission[];
tasks: Task[];
}): ReactElement {
const recentTasks = [...tasks]
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
.slice(0, 5);
return (
<div className="grid gap-6 lg:grid-cols-2">
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
{recentTasks.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No tasks yet</p>
</div>
) : (
<div className="space-y-2">
{recentTasks.map((task) => (
<div
key={task.id}
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2"
>
<span className="truncate text-sm text-text-primary">{task.title}</span>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-xs',
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{task.status}
</span>
</div>
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
{missions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No missions yet</p>
</div>
) : (
<MissionTimeline missions={missions.slice(0, 4)} />
)}
</section>
{project.metadata && Object.keys(project.metadata).length > 0 ? (
<section className="lg:col-span-2">
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<pre className="overflow-x-auto text-xs text-text-muted">
{JSON.stringify(project.metadata, null, 2)}
</pre>
</div>
</section>
) : null}
</div>
);
}
function StatCard({
label,
value,
valueClass,
}: {
label: string;
value: string;
valueClass?: string;
}): ReactElement {
return (
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
<p className="text-xs text-text-muted">{label}</p>
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
</div>
);
}
function getPrdContent(project: Project): string | null {
if (!project.metadata) return null;
const prd = project.metadata['prd'];
if (typeof prd === 'string' && prd.trim().length > 0) {
return prd;
}
const prdContent = project.metadata['prdContent'];
if (typeof prdContent === 'string' && prdContent.trim().length > 0) {
return prdContent;
}
return null;
}
+131
View File
@@ -0,0 +1,131 @@
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';
import { projectFixtures } from './page-fixtures';
const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
api: apiMock,
}));
import { ProjectsPage } from './projects';
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
apiMock.mockReset();
});
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
const routes: RouteObject[] = [
{ path: '/projects', element: <ProjectsPage /> },
{ path: '/projects/:id', element: <p>Project detail target</p> },
];
const router = createMemoryRouter(routes, { initialEntries: ['/projects'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
return router;
}
describe('ProjectsPage', () => {
it('shows a visible loading state while the project request is in flight', async () => {
const deferred = createDeferred<typeof projectFixtures>();
apiMock.mockReturnValueOnce(deferred.promise);
await renderProjectsPage();
expect(container.textContent).toContain('Loading projects...');
await act(async () => {
deferred.resolve(projectFixtures);
await deferred.promise;
});
});
it('renders project cards from the API and navigates to a project detail route on click', async () => {
apiMock.mockResolvedValueOnce(projectFixtures);
const router = await renderProjectsPage();
expect(apiMock).toHaveBeenCalledWith('/api/projects');
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Agent Runtime');
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes('Mosaic Stack'),
);
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(router.state.location.pathname).toBe('/projects/project-1');
expect(container.textContent).toContain('Project detail target');
});
it('renders the empty state when the API returns no projects', async () => {
apiMock.mockResolvedValueOnce([]);
await renderProjectsPage();
expect(container.textContent).toContain('No projects yet');
expect(container.textContent).toContain(
'Projects will appear here when created via the gateway API',
);
});
it('renders a visible alert when the projects request fails', async () => {
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Projects are unavailable');
});
});
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState, type ReactElement } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProjectCard } from '@/components/projects/project-card';
import { api } from '@/lib/api';
import type { Project } from '@/lib/types';
import { getErrorMessage } from './page-errors';
export function ProjectsPage(): ReactElement {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void api<Project[]>('/api/projects')
.then((response) => {
if (cancelled) return;
setProjects(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load projects.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Projects</h1>
</header>
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
</div>
) : null}
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.length === 0 ? (
<div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted">
Projects will appear here when created via the gateway API
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
onClick={(selectedProject) => navigate(`/projects/${selectedProject.id}`)}
/>
))}
</div>
)}
</div>
);
}
+116
View File
@@ -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,86 @@
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';
function Boom(): never {
throw new Error('resource route render blew up');
}
function replaceRouteElementWithBoom(nodes: RouteObject[], path: string): RouteObject[] {
return nodes.map((node) => {
const cloned: RouteObject = { ...node };
if (cloned.path === path) {
cloned.element = <Boom />;
}
if (cloned.children) {
cloned.children = replaceRouteElementWithBoom(cloned.children, path);
}
return cloned;
});
}
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
useSessionMock.mockReset();
});
describe('resource route error boundaries', () => {
it.each(['/projects', '/projects/:id', '/tasks'])(
'renders a recoverable fallback when %s throws during route render',
async (path) => {
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
const initialEntry = path === '/projects/:id' ? '/projects/project-1' : path;
const router = createMemoryRouter(replaceRouteElementWithBoom(routes, path), {
initialEntries: [initialEntry],
});
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,55 @@
import type { ReactElement } from 'react';
import { useRouteError } from 'react-router-dom';
interface ResourceRouteErrorBoundaryProps {
message: string;
href: string;
linkLabel: string;
}
function ResourceRouteErrorBoundary({
message,
href,
linkLabel,
}: ResourceRouteErrorBoundaryProps): 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">{message}</p>
<a href={href} className="text-sm underline">
{linkLabel}
</a>
</div>
);
}
export function ProjectsRouteErrorBoundary(): ReactElement {
return (
<ResourceRouteErrorBoundary
message="Something went wrong loading projects."
href="/projects"
linkLabel="Reload projects"
/>
);
}
export function ProjectDetailRouteErrorBoundary(): ReactElement {
return (
<ResourceRouteErrorBoundary
message="Something went wrong loading this project."
href="/projects"
linkLabel="Back to projects"
/>
);
}
export function TasksRouteErrorBoundary(): ReactElement {
return (
<ResourceRouteErrorBoundary
message="Something went wrong loading tasks."
href="/tasks"
linkLabel="Reload tasks"
/>
);
}
@@ -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',
});
});
});
+91
View File
@@ -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>
);
}
+144
View File
@@ -0,0 +1,144 @@
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';
import { taskFixtures } from './page-fixtures';
const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
api: apiMock,
}));
import { TasksPage } from './tasks';
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
apiMock.mockReset();
});
async function renderTasksPage(): Promise<void> {
const routes: RouteObject[] = [{ path: '/tasks', element: <TasksPage /> }];
const router = createMemoryRouter(routes, { initialEntries: ['/tasks'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
}
function clickButtonByText(text: string): void {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
describe('TasksPage', () => {
it('shows a visible loading state before the tasks request settles', async () => {
const deferred = createDeferred<typeof taskFixtures>();
apiMock.mockReturnValueOnce(deferred.promise);
await renderTasksPage();
expect(container.textContent).toContain('Loading tasks...');
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
});
it('starts in kanban view, toggles to list view, and opens the read-only modal from cards and rows', async () => {
apiMock.mockResolvedValueOnce(taskFixtures);
await renderTasksPage();
expect(container.textContent).toContain('Not Started');
expect(container.textContent).toContain('In Progress');
expect(container.textContent).toContain('Blocked');
const kanbanCard = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes('Route /projects/:id'),
);
expect(kanbanCard).toBeTruthy();
await act(async () => {
kanbanCard?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
await act(async () => {
container
.querySelector('button[aria-label="Close task details"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.querySelector('[role="dialog"]')).toBeNull();
await act(async () => {
clickButtonByText('List');
});
const row = [...container.querySelectorAll('tr')].find((candidate) =>
candidate.textContent?.includes('Route /tasks'),
);
expect(row).toBeTruthy();
await act(async () => {
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
expect(container.textContent).toContain('Wire list and kanban modal interactions');
});
it('renders a visible alert when the tasks request fails', async () => {
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderTasksPage();
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Tasks request failed');
});
});
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState, type ReactElement } from 'react';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { getErrorMessage } from './page-errors';
type ViewMode = 'list' | 'kanban';
export function TasksPage(): ReactElement {
const [tasks, setTasks] = useState<Task[]>([]);
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
useEffect(() => {
let cancelled = false;
void api<Task[]>('/api/tasks')
.then((response) => {
if (cancelled) return;
setTasks(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load tasks.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex rounded-lg border border-surface-border">
<button
type="button"
onClick={() => setView('list')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'list'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
List
</button>
<button
type="button"
onClick={() => setView('kanban')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'kanban'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
Kanban
</button>
</div>
</header>
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
</div>
) : null}
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
) : (
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
)}
{selectedTask ? (
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
) : null}
</div>
);
}
+9
View File
@@ -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>
);
}
+97
View File
@@ -0,0 +1,97 @@
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';
import { ProjectDetailPage } from '@/spa/pages/project-detail';
import { ProjectsPage } from '@/spa/pages/projects';
import { TasksPage } from '@/spa/pages/tasks';
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);
});
it.each([
['/projects', ProjectsPage],
['/projects/:id', ProjectDetailPage],
['/tasks', TasksPage],
])(
'renders a real authenticated page instead of the P1 placeholder at %s',
(path, expectedType) => {
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);
expect(element.type).toBe(expectedType);
},
);
it.each(['/chat', '/projects', '/projects/:id', '/tasks'])(
'defines an error boundary for %s',
(path) => {
const route = findRoute(routes, path);
expect(route?.errorElement).toBeTruthy();
expect(isValidElement(route?.errorElement)).toBe(true);
},
);
});
+14
View File
@@ -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);
});
});
+23
View File
@@ -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,
},
});
}
+25
View File
@@ -0,0 +1,25 @@
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,
strictPort: true,
proxy: {
'/api': gatewayTarget,
'/socket.io': { target: gatewayTarget, ws: true },
},
},
});
+12
View File
@@ -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/**'],
}, },
}); });
+17 -4
View File
@@ -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"]
+3 -1
View File
@@ -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
@@ -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.
+65
View File
@@ -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.
+172
View File
@@ -0,0 +1,172 @@
# WebUI Phase P — P2 Data + Auth Scratchpad
**Task ID:** WEBUI-P2
**Tracking ref:** stacked on PR #1143 (`feat/webui-p1-vite-skeleton`); no separate issue specified in the author brief
**Branch:** `feat/webui-p2-data-auth`
**Started:** 2026-08-09
**Role:** P2 author worker (must not modify `docs/TASKS.md`)
## Original tasking
> Read `/home/jwoltje/briefs/P2-brief.md` and execute it fully in `/home/jwoltje/src/stack-p1` on branch `feat/webui-p2-data-auth`. Run every listed verification gate, commit locally only, and do not push.
Container path mapping:
- Brief: `/home/jwoltje/distrobox-homes/mosaic-dev/briefs/P2-brief.md`
- Repo: `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack-p1`
## Objective
Make the P1 Vite SPA authenticate against the Mosaic Gateway by same-origin relative paths, replace guest-route placeholders with real login/register/SSO callback pages, and enforce guest/authenticated route guards without modifying the parallel Next app tree except for unavoidable shared-library import fixes.
## Scope and invariants
- All SPA HTTP and Socket.IO access remains origin-relative (`/api/...`, `/api/auth/...`, `/chat`).
- No `NEXT_PUBLIC_*`, `VITE_*` origin setting, or hard-coded `http://localhost:14242` under `apps/web/src/`.
- Verify the Gateway BetterAuth mount path from source before choosing auth-client configuration.
- Delete the legacy static SSO-provider discovery module and test; runtime `/api/sso/providers` is canonical.
- Preserve the Next build while adding React Router guest pages and session guards.
- Never push; stage named files only; do not touch the modified `.mosaic/orchestrator/session.lock`.
## Plan
1. Inspect P1 SPA structure, shared libraries, legacy Next auth pages/components, Gateway auth mount, and current test setup.
2. Add/adjust tests first for relative API behavior, auth-client origin configuration, relative Socket.IO singleton behavior, and all guard session states; run focused tests and capture expected RED failures.
3. Implement shared-library relative networking and remove the legacy SSO-provider module/imports.
4. Port login, register, and provider callback pages to React Router and wire routes.
5. Implement session-aware guest/auth guards and satisfy focused tests.
6. Run focused tests, all brief verification gates, invariant searches, and an independent code/security review; remediate and re-run affected gates.
7. Update this scratchpad with evidence, stage named files only, and commit locally.
## Testing strategy
- TDD is required because this increment changes authentication/session behavior.
- Primary situational evidence: jsdom router guard state tests plus guest-page/auth-flow contract tests already present or added as needed.
- Baseline gates: web tests, Vite build, Next build, typecheck, lint, and root format check exactly as listed in the brief.
- Browser automation is not required by the brief for P2; if used, it will be headless only.
## Budget
No explicit user token cap was provided. Working soft cap: **30K tokens**, derived from a multi-file auth/frontend increment with tests, dual builds, review, and remediation. Keep implementation within the brief; no unrelated refactors or dependencies.
## Progress / evidence
- [x] Loaded mission protocol, active MVP manifest/scratchpad, top-level tasks, PRD, relevant frontend/auth/testing/type/review guides, and matching skills.
- [x] Resolved host paths to the Distrobox-mapped repo and brief.
- [x] Confirmed branch `feat/webui-p2-data-auth`, stacked at P1 commit `068d0f9b`.
- [x] Source and Gateway mount inspection complete.
- [x] RED tests observed.
- [x] Implementation complete.
- [x] Independent review complete and findings remediated.
- [x] Verification gates complete.
- [x] Local implementation commit created: `46d68e1f`.
### Source decisions
- Gateway source mounts BetterAuth at `/api/auth/` in `apps/gateway/src/auth/auth.controller.ts`; `packages/auth/src/auth.ts` configures `basePath: '/api/auth'`.
- BetterAuth 1.5.5 defaults its browser client to `/api/auth` when `baseURL` is omitted. `src/lib/auth-client.ts` therefore omits `baseURL`, preserving same-origin behavior without encoding any gateway origin.
- The only `src/app/` edit is the transitional Next provider redirect page. Deleting `src/lib/sso-providers.ts` required that mechanical consumer migration; it now uses the same runtime `/api/sso/providers` discovery and callback sanitizer as the SPA.
- GuestGuard pending behavior intentionally follows the approved brief: if no session object exists (including pending), render `<Outlet />`; only a present session redirects to `/chat`. AuthGuard alone renders the specified pending treatment.
### TDD evidence
- Initial focused run: 9 expected failures, proving absolute API/auth/socket origins, P1 guest placeholders, and missing guard redirects/loading behavior.
- After implementation: focused contract suite 15/15 passed.
- Security remediation RED: `%2F%0A%2F%2Fevil.example` reached `signIn.oauth2` as an external-normalizing callback target before the fix.
- Security remediation GREEN: shared `resolveAuthCallbackURL` unit suite 9/9 plus SPA callback regression 1/1 passed; both SPA and Next consumers use the shared helper.
### Independent review
- Primary Codex wrappers could not run because `jq` is absent; direct Codex fallback then failed authentication with HTTP 401. No review result was claimed from those attempts.
- Independent Claude Sonnet code review found no implementation-scope blocker, one pending-state UX suggestion, and one pre-existing `api.ts` type-assertion suggestion. The pending-state suggestion was rejected because it contradicts the brief's explicit GuestGuard contract; the API cleanup is outside P2's preserve-contract scope.
- Independent Claude Sonnet security review found a high-severity control-character open redirect in the callback prefix check shared by the newly ported SPA logic and transitional Next consumer.
- Remediation centralized WHATWG URL parsing plus exact current-origin comparison in `src/lib/auth-redirect.ts`, returns only path/search/hash, and added the RED-first regression above.
- Fresh code re-review: `approve`, 0 blockers, 0 should-fix findings.
- Fresh security re-review: `low`, 0 critical/high/medium/low findings.
### Documentation checklist disposition
- `docs/PRD.md` exists and P2 aligns to FR-8 / AC-7 authentication requirements.
- No Gateway endpoint, DTO, permission, or API schema changed; existing `/api/auth/*` and `/api/sso/providers` contracts are consumed unchanged, so OpenAPI/API-index updates are not applicable.
- Route paths were already present in the P1 route table; P2 replaces placeholders without changing site-map navigation, so `docs/SITEMAP.md` is unchanged.
- User/admin auth behavior is parity with the still-live Next implementation, not a new workflow. Implementation decisions, failure behavior, testing, and migration compatibility are documented here; no publishing action is in scope.
- Documentation remains in-repo; no generated publishing output was created.
### Final verification evidence
Run fresh after remediation from the locations required by the brief:
- `apps/web: pnpm test` — PASS: `Test Files 9 passed (9)`; `Tests 27 passed (27)`.
- `apps/web: pnpm build:vite` — PASS: `✓ 113 modules transformed`; `✓ built in 565ms`.
- `apps/web: pnpm build` — PASS: `✓ Compiled successfully in 4.6s`; 10/10 static pages generated; dynamic provider/project routes retained.
- `apps/web: pnpm typecheck` — PASS: `tsc --noEmit` exited 0.
- `apps/web: pnpm lint` — PASS: `eslint src` exited 0.
- Root `pnpm format:check` — PASS: `All matched files use Prettier code style!`.
- Invariant scan — PASS: no `NEXT_PUBLIC_*`, `VITE_*`, `GATEWAY_URL`, `http://localhost:14242`, or `sso-providers` references under `apps/web/src`.
- `git diff --check` — PASS.
- All six required gates above were repeated successfully after implementation commit `46d68e1f`, proving the exact committed source tree.
## Risks / blockers
- `.mosaic/orchestrator/session.lock` is modified by the active harness and must remain unstaged.
- P2 changes shared `src/lib/` modules consumed by both Vite and Next, so the Next build is a required compatibility gate.
## Remediation 1 — independent Node 26 verification failure
**Correction received:** 2026-08-09
The orchestrator rejected the P2 verification claim after an independent run under Node 26.4.0 produced 2 failed redirect tests and 2 unhandled errors. React Router passed jsdom's realm-local `AbortSignal` to Node 26's native undici `Request`, which rejects non-native signals. Production guard behavior is correct and must not change.
### Remediation constraints
- Preserve both redirect assertions and all `guards.tsx` behavior; no skips, weakening, or test deletion.
- Add the smallest Vitest environment repair so global `AbortController` / `AbortSignal` are constructors accepted by native undici `Request`.
- Run all six required gates, commit named files locally without pushing, leave `.mosaic/orchestrator/session.lock` untouched, then run `apps/web: pnpm test` as the final worktree action.
- Completion requires zero failed tests, zero test errors, and zero unhandled rejections.
### Remediation plan
1. Reproduce under Node 26.4.0 if an ephemeral matching runtime is available.
2. Add a Vitest `setupFiles` module deriving Node-native abort constructors from `node:util` and register it in `apps/web/vitest.config.ts`.
3. Add a direct regression assertion that a global controller's signal is accepted by native `Request`, while retaining the existing redirect behavior tests unchanged.
4. Run focused Node 22 and Node 26 tests, independent review, all required gates, and local commit(s).
5. After every edit/commit/status check is complete, run `pnpm test` from `apps/web` as the last command.
### Remediation implementation and evidence
- Reproduced under ephemeral Node `v26.4.0`: `Test Files 1 failed (1)`, `Tests 2 failed | 4 passed (6)`, `Errors 2 errors`, with the exact undici `AbortSignal` realm rejection from the remediation brief.
- Added `apps/web/src/test/setup.ts`, registered through `vitest.config.ts#setupFiles`. It derives Node-native abort constructors from the built-in `node:util.transferableAbortController()` and aligns both `globalThis` and jsdom `window`; it does not replace `Request`, `Response`, or `fetch`.
- Explicitly pinned Vitest `isolate: true` so the test-only global constructors cannot leak between test-file environments.
- Added `src/test/setup.spec.ts`, which proves a global controller signal is accepted by Node's native `Request` and that abort propagation remains functional.
- Existing `guards.spec.tsx` and production `guards.tsx` remain unchanged.
- Focused Node 26.4.0 remediation run: `Test Files 2 passed (2)` and `Tests 7 passed (7)`, with zero errors/unhandled rejections.
- Preliminary full Node 26.4.0 run: `Test Files 9 passed (9)` and `Tests 27 passed (27)`, with zero errors/unhandled rejections before the direct setup regression was added.
- No dependency was added; `node:util` is a Node built-in.
### Remediation independent review
- First code review: approve, 0 blockers, 0 should-fix; suggested a direct Request regression and version-neutral comment.
- First security/integrity review: low risk; suggested pinning test isolation explicitly.
- All suggestions were applied.
- Fresh code re-review: approve, 0 blockers, 0 should-fix, no findings.
- Fresh security/integrity re-review: low risk, 0 findings.
### Remediation pre-commit gate evidence
- Node 26.4.0 `pnpm test`: `Test Files 10 passed (10)`; `Tests 28 passed (28)`; zero errors and zero unhandled rejections.
- `pnpm typecheck`: `tsc --noEmit` exited 0.
- `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 960ms`.
- `pnpm build` (Next): `✓ Compiled successfully in 4.8s`; static pages generated 10/10.
- `pnpm lint`: `eslint src` exited 0.
- Root `pnpm format:check`: `All matched files use Prettier code style!`.
- Final post-commit non-test gates and final-action Node 26 `pnpm test` remain required before reporting completion.
### Remediation committed verification
- Implementation commit: `e16c08aa` (`test(web): align jsdom abort signals with Node`).
- Post-commit `pnpm typecheck`: `tsc --noEmit` exited 0.
- Post-commit `pnpm build:vite`: `✓ 113 modules transformed`; `✓ built in 323ms`.
- Post-commit `pnpm build` (Next): `✓ Compiled successfully in 4.6s`; static pages generated 10/10.
- Post-commit `pnpm lint`: `eslint src` exited 0.
- Post-commit root `pnpm format:check`: `All matched files use Prettier code style!`.
- Final Node 26 `pnpm test` will be the last worktree action and its verbatim output will be reported to the orchestrator.
+271
View File
@@ -0,0 +1,271 @@
# WebUI Phase P — P4-1 Projects + Tasks SPA Scratchpad
**Task ID:** P4-1
**Tracking ref:** Phase P RFC §6.4 / §2.4 author brief; no provider issue supplied
**Mission context:** `mvp-20260312` (active)
**Branch:** `feat/webui-p4-1` from `origin/next`
**Started:** 2026-08-11
**Role:** orchestrator-controlled author worker; `docs/TASKS.md` remains orchestrator-only and is not part of this increment
## Original tasking
Implement the bounded P4-1 SPA parity slice exactly as briefed: real authenticated `/projects`, `/projects/:id`, and `/tasks` React Router pages ported from the existing Next baseline; reuse existing project/task components; preserve relative same-origin REST access; support only the existing project/task PATCH edit flows; add route error boundaries and page tests; pin Vite strict port 3100 and legacy Next dev/start to 3101; make no gateway, package, dependency, nav-shell, chat, settings, or admin changes; verify, commit, and push only `feat/webui-p4-1` (no PR or merge). If any required REST endpoint is absent, stop as `BLOCKED:` rather than inventing a workaround.
## Objective and acceptance map
- A: `/projects` list with loading, cards, empty, and surfaced API-error states; defer MissionStatus side panel.
- B: `/projects/:id` detail with overview/tasks/missions tabs, parallel project/mission/task load, bounded project and task PATCH edits.
- C: `/tasks` list/kanban with bounded task PATCH edits.
- D: replace the three route placeholders and add route error boundaries without changing chat.
- E: use only `@/lib/types` and `@/lib/api`; relative `/api/...` REST paths only.
- F: Vite `strictPort: true`; Next dev/start pinned to 3101; no proxy/dependency changes.
- G: fixture-backed Vitest coverage for lists, states, tabs/toggles, PATCH calls, and real route elements; all named verification gates pass.
## Plan
1. Verify the required gateway REST routes and inspect the exact `origin/next` SPA, Next baseline pages, shared components, types, API helper, and P3 test conventions.
2. Record the pre-change web test count and add required page/route tests first, observing expected RED failures.
3. Port the three pages and shared route error boundary, then wire routes and bounded PATCH flows.
4. Apply only the two dev-port pin changes.
5. Run focused tests, all user-required gates, lockfile and same-origin checks, and `git diff --check`.
6. Obtain independent spec/code/security review; remediate and repeat affected gates until clear.
7. Commit conventionally, run the required pre-push queue guard, push only the feature branch, and record exact evidence here.
## Testing strategy
TDD is applied because this adds user-visible data/edit behavior. Component tests mock only the existing API boundary and exercise rendered behavior and PATCH payloads. Primary situational evidence is the required page interaction suite plus route-resolution checks; baseline evidence is typecheck, lint, full web test, build, frozen install, formatting/diff hygiene, and same-origin grep.
## Budget
No explicit token cap was supplied. Working soft cap: **50K tokens**, derived from three coupled React pages, route/error wiring, interaction tests, port config, review/remediation, and full verification. One Codex implementation worker and independent review workers will be used serially to avoid worktree collisions.
## Base evidence
- `git fetch origin` rc=0.
- `origin/next` and branch start: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`.
- Branch created: `feat/webui-p4-1` tracking `origin/next`.
- Harness-owned `.mosaic/orchestrator/session.lock` is dirty and must remain unstaged/uncommitted.
## Progress / evidence
- [x] Loaded active mission manifest, latest scratchpad, top-level tasks, PRD, orchestration/delivery/frontend/QA/documentation/review/TypeScript guides, and matching implementation skills.
- [x] Confirmed exact base SHA and created the feature branch.
- [x] Required REST endpoints verified in Gateway source: project list/detail/PATCH, task list/filter/detail/PATCH, and mission list all exist.
- [x] Scope assumption check found a blocking contradiction before source implementation.
- [ ] Pre-change web test count recorded.
- [ ] RED tests observed.
- [ ] Implementation complete.
- [ ] Independent review clear.
- [x] Required verification gates run against the unchanged web baseline; exact leak-grep expectation is independently blocked by 12 pre-existing matches.
- [x] Blocker record committed as `5ede86a5` and feature branch pushed; no PR opened and no merge performed.
## Blocker — 2026-08-11
`P4-1` is blocked because the bounded edit UX asserted by the brief does not exist at the confirmed `origin/next` base (`e00cc475`):
- `apps/web/src/components/tasks/task-detail-modal.tsx` is read-only. Its props are only `task` and `onClose`; it contains no input/select/textarea, update callback, or `api()`/PATCH call.
- `apps/web/src/app/(dashboard)/projects/[id]/page.tsx` contains no project edit controls and no project/task PATCH call.
- `apps/web/src/app/(dashboard)/tasks/page.tsx` does not open `TaskDetailModal`; its click handler only logs that a future detail view will be added.
- A repository grep over those reference/component paths finds zero `method: 'PATCH'` calls and zero edit/save form controls.
- The required leak grep also disproves the brief's baseline claim that `apps/web/src` is clean: the exact command returns rc=0 with 12 pre-existing matches at `origin/next` (external font links, an avatar placeholder, an SVG namespace, and test fixtures). There are zero new matches in the worktree, but making the exact grep empty would require unrelated changes the brief forbids.
The brief simultaneously requires tests proving task/project PATCH edits and forbids inventing edit UX, fields, or flows beyond what those references expose. Adding controls or callback contracts would therefore be new design outside the authorized port/wiring scope. Per the explicit stop rule, no SPA source, route, config, package, lockfile, or test implementation was started.
## Verification evidence on unchanged web baseline
These gates were still run to distinguish the brief contradiction from a broken base:
- `pnpm --filter @mosaicstack/web typecheck` — rc=0.
- `pnpm --filter @mosaicstack/web lint` — rc=0.
- `pnpm --filter @mosaicstack/web test` — rc=0; 15 files, 133 tests passed. Before→after remains 133→133 because implementation was halted.
- `pnpm --filter @mosaicstack/web build` — rc=0; Next production build completed, 10/10 static pages generated.
- `pnpm install --frozen-lockfile` — rc=0; lock SHA-256 remained `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`; `git diff` for the lockfile rc=0.
- `git grep -nE "http://|https://" -- apps/web/src` — rc=0 with 12 pre-existing matches; `origin/next` count=12, current HEAD count=12, new worktree additions=0.
- `pnpm format:check` — rc=0.
- `git diff --check` — rc=0.
## Delivery evidence
- Initial queue-guard invocation through shell `~` failed rc=127 because this Distrobox resolves `HOME` to `/home/jwoltje/distrobox-homes/mosaic-dev`, where the injected fleet status already reports the tools installation missing.
- Correct supported host-tool invocation `/home/jwoltje/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B feat/webui-p4-1` — rc=0; branch absent remotely and queue clear.
- `git push -u origin feat/webui-p4-1` — rc=0. The push hook additionally ran repository preflight, typecheck (45/45 tasks), lint (25/25 tasks), and format check successfully.
- No PR was opened and no merge was attempted, per the brief.
## Risks / blockers
- The active harness mutates `.mosaic/orchestrator/session.lock`; it is excluded from staging.
- The Phase P §6.4 prose is absent from this checkout, so the brief-named route spec and existing Next pages are the bounded implementation anchors.
- The MissionStatus `/api/coord/status` panel is explicitly deferred to a follow-up and must not enter P4-1.
## REV 2 continuation — 2026-08-10T21:59:17-05:00
REV 2 supersedes the original tasking above. The independent check confirmed that the Next reference pages and `task-detail-modal.tsx` are read-only, so project/task editing is deliberately deferred to P4-1b. Do not re-litigate or implement the former PATCH requirements.
### Revised objective and acceptance map
- A: `/projects` read-only SPA list with loading, cards, empty, and surfaced API-error states; card navigation to `/projects/:id`; no MissionStatus panel.
- B: `/projects/:id` read-only SPA detail using `useParams`, `useNavigate`, and the specified three-request `Promise.all`; preserve reference overview/tasks/missions tabs and read-only task modal.
- C: `/tasks` read-only SPA list/kanban view with the existing read-only task modal.
- D: replace only the three route placeholders and add page-local route error boundaries without modifying chat.
- E: use only `@/lib/types` and `@/lib/api` with relative `/api/...` REST paths.
- F: add Vite `strictPort: true` and pin legacy Next dev/start to 3101 without dependency or proxy changes.
- G: add fixture-backed page tests and route-resolution assertions; run every user-specified verification gate and the narrowed new-code origin check.
### Revised plan
1. Reset `feat/webui-p4-1` to the exact `origin/next` base and independently verify the read-only reference/component/API assumptions.
2. Record the pre-change web suite count; write and run focused page/route tests first to observe expected RED failures.
3. Port the three read-only pages, add independent route error boundaries, wire routes, and apply only the two dev-port pins.
4. Run focused tests, full required web gates, frozen install/lockfile proof, origin check, format/diff hygiene, and accessibility/state-transition sanity checks.
5. Obtain independent spec/code/security review, remediate every blocker, and repeat affected gates.
6. Commit conventionally, run the supported pre-push queue guard, and push only `feat/webui-p4-1`; no PR and no merge.
### Revised budget and session state
- No explicit token cap was supplied. Working soft cap remains **50K tokens**.
- TDD is required by the user and frontend skill for the new SPA behavior; tests must fail for missing pages before production implementation.
- Documentation assessment: this is a parity port of existing read-only behavior, not a new public workflow or API contract; the task scratchpad is the required delivery record, with no user/developer/API documentation changes in this bounded increment.
- Exact base confirmed after fresh fetch/reset: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`.
- Remote `feat/webui-p4-1` is absent; the eventual push is a fresh branch creation.
- Harness-owned `.mosaic/orchestrator/session.lock` remains excluded from staging.
## REV 2 implementation pass — 2026-08-11T22:05:00Z
### Startup verification
- Loaded required startup files: `/home/jwoltje/.config/mosaic/CONSTITUTION.md`, `/home/jwoltje/.config/mosaic/SOUL.md`, project `AGENTS.md`, `/home/jwoltje/.config/mosaic/guides/E2E-DELIVERY.md`, `docs/PRD.md`, and this scratchpad.
- Loaded required skills: `test-driven-development`, `vitest`, `vite`, `next-best-practices`, and `verification-before-completion`.
- Loaded required runtime guide: `/home/jwoltje/.config/mosaic/runtime/codex/RUNTIME.md`.
- Structured reasoning tool availability verified through the harness before planning.
- `git rev-parse HEAD` confirmed exact required base: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`.
- `git status --short` at startup showed only the expected untracked append-only scratchpad; `.mosaic/orchestrator/session.lock` did not appear and must remain unstaged if it changes later.
### Current plan
1. Record baseline evidence.
2. Add focused failing SPA page/route specs first.
3. Run focused RED command and record the missing-page failure.
4. Implement the bounded read-only SPA pages, boundaries, route wiring, and the two dev-port pins.
5. Run the required verification matrix and inspect diff hygiene.
### Pre-change baseline
- Command: `pnpm --filter @mosaicstack/web test`
- rc=0
- File/test totals before changes: `15 files / 133 tests`
- Notes: baseline already includes `/chat` SPA route coverage and raw React `createRoot`/`act` page specs that this pass should mirror.
### RED evidence before production implementation
- Command: `pnpm --filter @mosaicstack/web test -- src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx`
- rc=1
- Expected missing-feature reason confirmed:
- `src/spa/pages/projects.spec.tsx`, `src/spa/pages/project-detail.spec.tsx`, `src/spa/pages/tasks.spec.tsx`, and `src/spa/routes.spec.tsx` fail import resolution because the SPA page modules do not exist yet.
- `src/spa/pages/resource-route-boundaries.spec.tsx` fails because the current `/projects`, `/projects/:id`, and `/tasks` routes still render placeholders without route-level alert fallbacks.
### Implementation summary
- Added SPA pages:
- `apps/web/src/spa/pages/projects.tsx`
- `apps/web/src/spa/pages/project-detail.tsx`
- `apps/web/src/spa/pages/tasks.tsx`
- Added page-local route boundary components in `apps/web/src/spa/pages/resource-route-error-boundaries.tsx`.
- Wired `/projects`, `/projects/:id`, and `/tasks` in `apps/web/src/routes.tsx` with real elements and `errorElement`s.
- Added fixture-backed raw React Vitest coverage plus route assertions for the three pages and their boundaries.
- Applied only the requested dev topology pins:
- `apps/web/vite.config.ts`: `server.strictPort = true`
- `apps/web/package.json`: `next dev -p 3101`, `next start -p 3101`
### Post-implementation verification
- Focused changed specs:
- Command: `pnpm --filter @mosaicstack/web exec vitest run src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx`
- rc=0
- Result: `5 files / 26 tests` passed.
- `pnpm --filter @mosaicstack/web typecheck` — rc=0.
- `pnpm --filter @mosaicstack/web lint` — rc=0.
- `pnpm --filter @mosaicstack/web test` — rc=0; post-change totals `19 files / 153 tests`.
- `pnpm --filter @mosaicstack/web build` — rc=1.
- Limitation: Next/Turbopack hit a sandbox/runtime failure while processing `apps/web/src/app/globals.css`: `creating new process`, `binding to a port`, `Operation not permitted (os error 1)`. This appears environmental, not route-code-specific.
- `pnpm --filter @mosaicstack/web build:vite` — rc=0.
- `pnpm install --frozen-lockfile` — rc=1.
- Limitation: repo `prepare` hook attempted to lock `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack/.git/config`, which is read-only in this harness.
- `pnpm-lock.yaml` SHA-256 before/after install attempt: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`.
- `origin/next` `pnpm-lock.yaml` SHA-256: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`.
- `git diff -- pnpm-lock.yaml` — rc=0 (unchanged).
- `git diff -- apps/web/src | grep -nE '^\+.*(fetch|io|api)\(\s*[\x27\"]https?://'` — rc=1 (empty, as required).
- `pnpm format:check` — rc=0.
- `git diff --check` — rc=0.
### Final worktree check
- `git status --short` shows only the authorized web files plus this scratchpad.
- `.mosaic/orchestrator/session.lock` remains unstaged.
- Current changed file set:
- `apps/web/package.json`
- `apps/web/src/routes.tsx`
- `apps/web/src/spa/routes.spec.tsx`
- `apps/web/vite.config.ts`
- `apps/web/src/spa/pages/page-errors.ts`
- `apps/web/src/spa/pages/page-fixtures.ts`
- `apps/web/src/spa/pages/project-detail.spec.tsx`
- `apps/web/src/spa/pages/project-detail.tsx`
- `apps/web/src/spa/pages/projects.spec.tsx`
- `apps/web/src/spa/pages/projects.tsx`
- `apps/web/src/spa/pages/resource-route-boundaries.spec.tsx`
- `apps/web/src/spa/pages/resource-route-error-boundaries.tsx`
- `apps/web/src/spa/pages/tasks.spec.tsx`
- `apps/web/src/spa/pages/tasks.tsx`
- `docs/scratchpads/webui-p4-1.md`
## P4-1 REV 2 Final Delivery Verification — 2026-08-10T22:23:00Z
### Spec/Security Review Verdicts (Independent Reviews)
Both independent reviews cleared P4-1 REV 2 implementation without blockers:
- **Spec review:** approved; 0 blockers, 0 should-fix findings
- **Code/security review:** approved; 0 blockers, 0 critical/high findings, 0 should-fix recommendations
### Final Fresh Gate Verification (This Session)
All verification gates executed sequentially with rc=0 (except where noted):
1. `pnpm --filter @mosaicstack/web typecheck` — rc=0
2. `pnpm --filter @mosaicstack/web lint` — rc=0
3. `pnpm --filter @mosaicstack/web test` — rc=0; **19 files / 153 tests** (baseline: 15 files / 133 tests; added: 4 files / 20 tests)
4. `pnpm --filter @mosaicstack/web build` — rc=0; Next production build completed, 10/10 static pages generated
5. `pnpm --filter @mosaicstack/web build:vite` — rc=0; Vite production bundle generated
6. Lockfile integrity:
- Before install: SHA-256 `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`
- After frozen install: SHA-256 `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`
- Equality: ✓ verified
- `git diff --quiet origin/next -- pnpm-lock.yaml` rc=0 ✓
7. `pnpm format:check` — rc=0; all matched files use Prettier code style
8. `git diff --check` — rc=0; no trailing whitespace or merged conflict markers
9. New-code origin check: `git diff -- apps/web/src | grep -nE '^\+.*(fetch|io|api)\(\s*[\x27\"]https?://'` — rc=1 (empty result, as required)
10. `pnpm --filter @mosaicstack/web exec vitest run src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx` — rc=0; 5 files / 26 tests passed
### Scope Audit
**Authorized in-scope changes:**
- ✓ `apps/web/package.json` — dev port pins only
- ✓ `apps/web/vite.config.ts` — strictPort flag only
- ✓ `apps/web/src/routes.tsx` — route wiring with errorElements
- ✓ `apps/web/src/spa/pages/projects.tsx` — read-only SPA list
- ✓ `apps/web/src/spa/pages/project-detail.tsx` — read-only SPA detail
- ✓ `apps/web/src/spa/pages/tasks.tsx` — read-only SPA list/kanban
- ✓ `apps/web/src/spa/pages/resource-route-error-boundaries.tsx` — page-local error components
- ✓ `apps/web/src/spa/pages/*.spec.tsx` — fixture-backed tests (4 new)
- ✓ `docs/scratchpads/webui-p4-1.md` — append-only record
**Out-of-scope verification:**
- `.mosaic/orchestrator/session.lock` — not modified/staged ✓
- `pnpm-lock.yaml` — not modified ✓
- `apps/gateway/**` — not modified ✓
- `packages/**` — not modified ✓
- Chat SPA — not modified ✓
- Settings, admin, navigation, or auth flow — not modified ✓
### Next Action
Commit, run authorized queue guard, and push only `feat/webui-p4-1` (no PR, no merge).
@@ -465,12 +465,24 @@ while true; do
no-status) no-status)
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2 echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
else exit 3
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
fi fi
if [[ "$PURPOSE" == "push" ]]; then
echo "[ci-queue-wait] queue-clear state=no-status purpose=push branch=${BRANCH}; no queued or running CI."
exit 0
fi
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3 exit 3
;; ;;
terminal-failure|malformed|unknown) terminal-failure)
if [[ "$PURPOSE" == "push" ]]; then
echo "[ci-queue-wait] queue-clear state=terminal-failure purpose=push branch=${BRANCH}; no queued or running CI."
exit 0
fi
echo "Error: ASSERTED_NOT_READY state=terminal-failure purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
malformed|unknown)
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2 echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3 exit 3
;; ;;
@@ -94,8 +94,8 @@ BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print(
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')" HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')" HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')" HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2 echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
exit 1 exit 1
fi fi
@@ -43,22 +43,22 @@ SH
chmod +x "$STUB_DIR/gh" chmod +x "$STUB_DIR/gh"
run_guard() { run_guard() {
local mode="$1" local mode="$1" purpose="${2:-push}"
( (
cd "$REPO_DIR" || exit cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH" export PATH="$STUB_DIR:$PATH"
export MOSAIC_GH_CHECK_MODE="$mode" export MOSAIC_GH_CHECK_MODE="$mode"
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log" export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl" export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0 "$SCRIPT_DIR/ci-queue-wait.sh" --purpose "$purpose" -t 0 -i 0
) )
} }
failures=0 failures=0
assert_case() { assert_case() {
local mode="$1" expected_rc="$2" expected_state="$3" output rc local mode="$1" expected_rc="$2" expected_state="$3" purpose="${4:-push}" output rc
set +e set +e
output=$(run_guard "$mode" 2>&1) output=$(run_guard "$mode" "$purpose" 2>&1)
rc=$? rc=$?
set -e set -e
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
@@ -79,10 +79,12 @@ set -e
: > "$WORK_DIR/gh-calls.log" : > "$WORK_DIR/gh-calls.log"
assert_case success zero terminal-success assert_case success zero terminal-success
assert_case pending nonzero pending assert_case pending nonzero pending
assert_case failure nonzero terminal-failure assert_case failure zero terminal-failure
assert_case late-failure nonzero terminal-failure assert_case late-failure zero terminal-failure
assert_case failure nonzero terminal-failure merge
assert_case late-failure nonzero terminal-failure merge
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 6 ]]; then
echo "FAIL: expected every case to query all Checks API pages" >&2 echo "FAIL: expected every case to query all Checks API pages" >&2
failures=$((failures + 1)) failures=$((failures + 1))
fi fi
@@ -92,4 +94,4 @@ if [[ "$failures" -ne 0 ]]; then
exit 1 exit 1
fi fi
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)" echo "GitHub check-runs regression passed (6/6 purpose-aware cases, including later-page failure)"
@@ -53,6 +53,7 @@ case "$url" in
malformed) printf '%s' 'not-json' ;; malformed) printf '%s' 'not-json' ;;
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;; malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;; malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
unknown) printf '%s' '{"state":"success","statuses":[{"status":"cancelled"}]}' ;;
large-success) large-success)
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")' python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
;; ;;
@@ -128,18 +129,27 @@ run_assertion() {
set -e set -e
: > "$WORK_DIR/urls.log" : > "$WORK_DIR/urls.log"
run_assertion success zero success 'state=terminal-success' # Push readiness is queue clearance, not proof that prior CI succeeded.
run_assertion pending nonzero pending 'ASSERTED_NOT_READY' run_assertion push-success zero success 'state=terminal-success'
run_assertion failure nonzero failure 'ASSERTED_NOT_READY' run_assertion push-pending nonzero pending 'ASSERTED_NOT_READY'
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY' run_assertion push-failure zero failure 'queue-clear state=terminal-failure purpose=push'
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY' run_assertion push-no-status zero no-status 'queue-clear state=no-status purpose=push'
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY' run_assertion push-aggregate-success-no-status zero aggregate-success-no-status 'queue-clear state=no-status purpose=push'
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY' run_assertion push-require-status nonzero no-status 'ASSERTED_NOT_READY state=no-status' --require-status
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY' run_assertion push-malformed nonzero malformed 'ASSERTED_NOT_READY'
run_assertion large-payload not126 large-success 'state=terminal-success' run_assertion push-malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
run_assertion push-malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
run_assertion push-unknown nonzero unknown 'ASSERTED_NOT_READY'
run_assertion push-large-payload not126 large-success 'state=terminal-success'
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT' run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT' run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
# Merge readiness remains fail-closed and requires exact-head terminal success.
MOSAIC_TEST_PURPOSE=merge run_assertion merge-success zero success 'state=terminal-success'
MOSAIC_TEST_PURPOSE=merge run_assertion merge-failure nonzero failure 'ASSERTED_NOT_READY state=terminal-failure'
MOSAIC_TEST_PURPOSE=merge run_assertion merge-no-status nonzero no-status 'ASSERTED_NOT_READY state=no-status'
MOSAIC_TEST_PURPOSE=merge run_assertion merge-unknown nonzero unknown 'ASSERTED_NOT_READY state=unknown'
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2 echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
failures=$((failures + 1)) failures=$((failures + 1))
@@ -17,9 +17,10 @@ make_fixture() {
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh" cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
git -C "$root/repo" init -q git -C "$root/repo" init -q
git -C "$root/repo" remote add origin "$remote" git -C "$root/repo" remote add origin "$remote"
local base_branch="${3:-main}"
cat > "$tools/pr-metadata.sh" <<SH cat > "$tools/pr-metadata.sh" <<SH
#!/usr/bin/env bash #!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}' printf '%s\n' '{"baseRefName":"$base_branch","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
SH SH
cat > "$tools/ci-queue-wait.sh" <<'SH' cat > "$tools/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -29,8 +30,8 @@ SH
} }
rm -rf "$WORK_DIR" rm -rf "$WORK_DIR"
make_fixture gitea https://git.example.test/acme/widgets.git make_fixture gitea https://git.example.test/acme/widgets.git next
make_fixture github https://github.com/acme/widgets.git make_fixture github https://github.com/acme/widgets.git main
cat > "$WORK_DIR/gitea/curl" <<'SH' cat > "$WORK_DIR/gitea/curl" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
+777 -71
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -75,6 +75,16 @@ export async function installHooks({
} = {}) { } = {}) {
if (disabled) return; if (disabled) return;
try {
await execFileAsync('git', ['--version']);
} catch (error) {
if (error.code === 'ENOENT') {
console.warn('git not found; skipping hook installation');
return;
}
throw error;
}
const huskyDir = path.join(root, '.husky'); const huskyDir = path.join(root, '.husky');
const active = path.join(huskyDir, '_'); const active = path.join(huskyDir, '_');
const nonce = `${Date.now()}-${process.pid}`; const nonce = `${Date.now()}-${process.pid}`;