Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61d8c5fdd9 | ||
|
|
df0085dbe2 | ||
|
|
61c495dd63 |
+3
-6
@@ -40,12 +40,9 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||
BETTER_AUTH_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
|
||||
# Directory holding the built SPA bundle (vite build output). When set, the
|
||||
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
|
||||
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
|
||||
# safe-default: unset in dev — SPA serving is an opt-in production concern
|
||||
#WEB_DIST_DIR=apps/web/dist
|
||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
||||
# Public gateway URL — accessible from the browser, not just the server.
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +23,3 @@ infra/step-ca/dev-password
|
||||
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
||||
# file vanished mid-scan. Ignoring them removes the race.
|
||||
*.timestamp-*.mjs
|
||||
|
||||
# Playwright run artifacts (#1445, P6 E2E gate)
|
||||
apps/web/test-results/
|
||||
apps/web/playwright-report/
|
||||
|
||||
@@ -254,23 +254,6 @@ steps:
|
||||
depends_on:
|
||||
- typecheck
|
||||
|
||||
# Canonical verify:release stage `build` (#1445, P6): every PR proves the
|
||||
# full workspace build — including the SPA `vite build` — before merge,
|
||||
# instead of leaving build breakage to surface post-merge in publish.yml's
|
||||
# verify step. Same canonical command the publish pipeline's build step runs.
|
||||
build:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
depends_on:
|
||||
# after test, not typecheck: turbo gives `test` a ^build dependency, so
|
||||
# running this step concurrently with test would put two independent
|
||||
# turbo builds on the same shared-workspace dist/ and turbo cache with
|
||||
# no cross-process locking — the same serialization invariant
|
||||
# publish.yml documents for #1411.
|
||||
- test
|
||||
|
||||
services:
|
||||
ci-postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
|
||||
+44
-94
@@ -407,96 +407,6 @@ steps:
|
||||
- build
|
||||
- verify
|
||||
|
||||
# #1445 (P6): headless Playwright E2E gate on every trunk merge. Boots the
|
||||
# real gateway on the embedded PGlite path (no DATABASE_URL, no services)
|
||||
# serving the built SPA bundle via WEB_DIST_DIR — the exact serving path the
|
||||
# gateway image ships (docker/gateway.Dockerfile sets WEB_DIST_DIR to the
|
||||
# baked bundle), which keeps #1407's parity guarantee: the image build steps
|
||||
# below depend on this gate, so a bundle that fails E2E never publishes.
|
||||
#
|
||||
# Image pinned to the @playwright/test version in pnpm-lock.yaml so the
|
||||
# image's bundled browsers match the workspace driver exactly (bump the two
|
||||
# together). The step installs no workspace packages (corepack does fetch
|
||||
# the pinned pnpm itself): it reuses the workspace node_modules
|
||||
# from `install` and the dist outputs from `build` — the gateway's runtime
|
||||
# dependency path is pure JS/WASM (PGlite is WASM, postgres-js is pure JS),
|
||||
# so the alpine-installed modules run unchanged under this glibc image.
|
||||
# depends_on publish-next-npm per the #1411 serialization invariant: this
|
||||
# step reads the workspace and must never run inside the manifest-transform
|
||||
# window.
|
||||
e2e:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
environment:
|
||||
GATEWAY_PORT: '14242'
|
||||
PLAYWRIGHT_BASE_URL: http://localhost:14242
|
||||
# The database is seeded by Playwright's globalSetup in this step, so
|
||||
# login failures are real failures: without this flag the suite's
|
||||
# skip-when-login-fails guards (a live-environment affordance) could
|
||||
# skip every authenticated spec and go green while proving nothing.
|
||||
E2E_REQUIRE_SEEDED_AUTH: '1'
|
||||
commands:
|
||||
- corepack enable
|
||||
- |
|
||||
# Throwaway signing secret for this step's ephemeral embedded database
|
||||
# (the gateway refuses to boot without one). Generated per run so no
|
||||
# usable literal lives in the tree.
|
||||
export BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)"
|
||||
export WEB_DIST_DIR="$(pwd)/apps/web/dist"
|
||||
if [ ! -f "$WEB_DIST_DIR/index.html" ]; then
|
||||
echo "[e2e] FATAL: $WEB_DIST_DIR/index.html missing — did the build step run?" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Boot the gateway from the built dist, cwd- AND HOME-isolated: the
|
||||
# local-tier PGlite database lives under $HOME/.config/mosaic/gateway/
|
||||
# (database.module.ts), not under cwd, so HOME must point at the
|
||||
# throwaway dir too or the run would share a database with anything
|
||||
# else in the container's home.
|
||||
GATEWAY_RUN_DIR="$(mktemp -d /tmp/e2e-gateway.XXXXXX)"
|
||||
(cd "$GATEWAY_RUN_DIR" && export HOME="$GATEWAY_RUN_DIR" && exec node "$OLDPWD/apps/gateway/dist/main.js") > /tmp/gateway.log 2>&1 &
|
||||
GATEWAY_PID=$!
|
||||
ready=0
|
||||
for i in $(seq 1 90); do
|
||||
if node -e "fetch('http://localhost:' + process.env.GATEWAY_PORT + '/health', { signal: AbortSignal.timeout(2000) }).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
||||
echo "[e2e] FATAL: gateway process exited during startup" >&2
|
||||
cat /tmp/gateway.log >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[e2e] waiting for gateway ($i/90)..."
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "[e2e] FATAL: gateway did not become ready in 90s" >&2
|
||||
cat /tmp/gateway.log >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[e2e] gateway ready; running Playwright suite"
|
||||
set +e
|
||||
pnpm --filter @mosaicstack/web exec playwright test
|
||||
E2E_EXIT=$?
|
||||
set -e
|
||||
kill "$GATEWAY_PID" 2>/dev/null || true
|
||||
if [ "$E2E_EXIT" -ne 0 ]; then
|
||||
echo "[e2e] FATAL: Playwright suite failed (exit $E2E_EXIT); gateway log follows" >&2
|
||||
tail -100 /tmp/gateway.log >&2
|
||||
echo "[e2e] browser-side traces/screenshots are under apps/web/test-results/ in the step workspace (not persisted past the pod)" >&2
|
||||
fi
|
||||
exit "$E2E_EXIT"
|
||||
# Same filter as the image builds it gates: a merge that publishes no
|
||||
# image (docs-only on main) pays no browser suite, and a skipped e2e does
|
||||
# not block anything (skipped-dependency semantics, same as
|
||||
# publish-next-npm on tag events).
|
||||
when: *image_build_when
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: never read the workspace inside publish-next-npm's
|
||||
# manifest-transform window.
|
||||
- publish-next-npm
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -556,8 +466,6 @@ steps:
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
|
||||
- e2e
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
@@ -602,5 +510,47 @@ steps:
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
|
||||
- e2e
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
REGISTRY_PASS:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next web publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@mariozechner/pi-ai": "^0.65.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { RequestMethod, type Type } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy route inventory (contract 1 §6.3).
|
||||
*
|
||||
* The hierarchy command family is a CLOSED enumeration asserted here, not a
|
||||
* prose claim: every hierarchy-flavored route the AppModule graph declares
|
||||
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
|
||||
* removing a hierarchy route without updating this inventory (and its
|
||||
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
|
||||
* baseline.
|
||||
*/
|
||||
|
||||
interface RouteEntry {
|
||||
method: string;
|
||||
path: string;
|
||||
controller: string;
|
||||
}
|
||||
|
||||
/** Module-metadata entry: a module class or a DynamicModule-shaped object. */
|
||||
type ModuleEntry =
|
||||
| Type<unknown>
|
||||
| { module: Type<unknown>; imports?: unknown[]; controllers?: Type<unknown>[] };
|
||||
|
||||
function collectControllers(root: ModuleEntry): Type<unknown>[] {
|
||||
const visited = new Set<unknown>();
|
||||
const controllers: Type<unknown>[] = [];
|
||||
const walk = (entry: ModuleEntry | undefined | null): void => {
|
||||
if (!entry || visited.has(entry)) return;
|
||||
visited.add(entry);
|
||||
const moduleClass = typeof entry === 'function' ? entry : entry.module;
|
||||
// Entries with no resolvable class (forwardRef wrappers, async dynamic
|
||||
// modules) carry no decorator metadata to read here.
|
||||
if (typeof moduleClass !== 'function') return;
|
||||
if (visited.has(moduleClass) && typeof entry !== 'function') return;
|
||||
visited.add(moduleClass);
|
||||
// 'controllers' / 'imports' are the metadata keys the @Module decorator writes.
|
||||
const declared = (Reflect.getMetadata('controllers', moduleClass) ?? []) as Type<unknown>[];
|
||||
controllers.push(...declared);
|
||||
if (typeof entry !== 'function' && entry.controllers) controllers.push(...entry.controllers);
|
||||
const imports = [
|
||||
...((Reflect.getMetadata('imports', moduleClass) ?? []) as ModuleEntry[]),
|
||||
...(typeof entry !== 'function' ? ((entry.imports ?? []) as ModuleEntry[]) : []),
|
||||
];
|
||||
for (const imported of imports) walk(imported);
|
||||
};
|
||||
walk(root);
|
||||
return controllers;
|
||||
}
|
||||
|
||||
function routesOf(controller: Type<unknown>): RouteEntry[] {
|
||||
// 'path' on the class is the @Controller prefix; 'path'/'method' on a
|
||||
// handler are written by the @Get/@Post/... route decorators.
|
||||
const base = (Reflect.getMetadata('path', controller) ?? '') as string | string[];
|
||||
const bases = Array.isArray(base) ? base : [base];
|
||||
const routes: RouteEntry[] = [];
|
||||
const prototype = controller.prototype as Record<string, unknown>;
|
||||
for (const name of Object.getOwnPropertyNames(prototype)) {
|
||||
if (name === 'constructor') continue;
|
||||
const handler = Object.getOwnPropertyDescriptor(prototype, name)?.value;
|
||||
if (typeof handler !== 'function') continue;
|
||||
const method = Reflect.getMetadata('method', handler) as number | undefined;
|
||||
if (method === undefined) continue;
|
||||
const sub = (Reflect.getMetadata('path', handler) ?? '/') as string;
|
||||
for (const prefix of bases) {
|
||||
const path = `/${prefix}/${sub}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
|
||||
routes.push({
|
||||
method: RequestMethod[method] ?? String(method),
|
||||
path,
|
||||
controller: controller.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
|
||||
* mutation audited via the M4-1b-i path or one of the two ratified reads
|
||||
* (granted companies, the §2.8 directory carve-out).
|
||||
*/
|
||||
const HIERARCHY_COMMAND_FAMILY = [
|
||||
'POST /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies/directory',
|
||||
'POST /api/hierarchy/companies/:id/rename',
|
||||
'POST /api/hierarchy/companies/:id/visibility',
|
||||
'DELETE /api/hierarchy/companies/:id',
|
||||
'POST /api/hierarchy/estates',
|
||||
'POST /api/hierarchy/estates/:id/rename',
|
||||
'POST /api/hierarchy/estates/:id/transfer',
|
||||
'DELETE /api/hierarchy/estates/:id',
|
||||
'POST /api/hierarchy/platform-projects',
|
||||
'POST /api/hierarchy/platform-projects/:id/rename',
|
||||
'POST /api/hierarchy/platform-projects/:id/transfer',
|
||||
'DELETE /api/hierarchy/platform-projects/:id',
|
||||
'POST /api/hierarchy/grants',
|
||||
'POST /api/hierarchy/grants/:id/change',
|
||||
'DELETE /api/hierarchy/grants/:id',
|
||||
] as const;
|
||||
|
||||
describe('hierarchy route inventory (§6.3)', () => {
|
||||
const inventory = collectControllers(AppModule).flatMap(routesOf);
|
||||
|
||||
it('control: the enumeration sees the known route surface', () => {
|
||||
const paths = inventory.map((r) => `${r.method} ${r.path}`);
|
||||
expect(paths).toContain('GET /health');
|
||||
expect(paths).toContain('POST /api/workspaces');
|
||||
expect(paths).toContain('GET /api/teams');
|
||||
expect(inventory.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('the hierarchy surface is exactly the declared command family', () => {
|
||||
const hierarchyRoutes = inventory
|
||||
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
|
||||
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
|
||||
const controllers = collectControllers(HierarchyModule);
|
||||
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
|
||||
const declared = controllers
|
||||
.flatMap(routesOf)
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ import { GCModule } from './gc/gc.module.js';
|
||||
import { HarnessModule } from './harness/harness.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -66,7 +65,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
||||
QueueModule,
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
HierarchyModule,
|
||||
...(federationEnabled ? [FederationModule] : []),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
|
||||
@@ -61,33 +61,6 @@ describe('CommandAuthorizationService', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies non-admin scopes to a platform admin (contract 2 §1.1 bypass retirement)', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
for (const scope of ['core', 'agent', 'skill', 'plugin'] as const) {
|
||||
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
|
||||
expect(
|
||||
(await service.authorize(command, { ...payload, command: command.name }, 'admin-1'))
|
||||
.allowed,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows member core/agent scopes and denies skill/plugin (deny-by-default)', async (): Promise<void> => {
|
||||
const service = createService('member');
|
||||
for (const [scope, allowed] of [
|
||||
['core', true],
|
||||
['agent', true],
|
||||
['skill', false],
|
||||
['plugin', false],
|
||||
] as const) {
|
||||
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
|
||||
expect(
|
||||
(await service.authorize(command, { ...payload, command: command.name }, 'member-1'))
|
||||
.allowed,
|
||||
).toBe(allowed);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
|
||||
const entries = new Map<string, string>();
|
||||
const action = {
|
||||
|
||||
@@ -154,15 +154,8 @@ export class CommandAuthorizationService {
|
||||
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: platform admin confers instance administration only —
|
||||
* the former admin-passes-every-scope short-circuit is retired. Admin
|
||||
* reaches exactly the admin scope; core/agent scopes belong to the member
|
||||
* role; skill/plugin scopes stay deny-for-all until a grant mapping names
|
||||
* them (§3.1 deny-by-default).
|
||||
*/
|
||||
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
||||
if (scope === 'admin') return role === 'admin';
|
||||
if (role === 'admin') return true;
|
||||
return role === 'member' && (scope === 'core' || scope === 'agent');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
companies,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyOutbox,
|
||||
platformProjects,
|
||||
runPgliteMigrations,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import {
|
||||
HierarchyAuditIdempotencyConflictError,
|
||||
HierarchyAuditRepository,
|
||||
HierarchyNodeNotFoundError,
|
||||
type AppendHierarchyEventInput,
|
||||
} from './hierarchy-audit.repository.js';
|
||||
|
||||
/**
|
||||
* Repository-level §6.4 witnesses for the hierarchy audit machinery
|
||||
* (contract 1 §5.2, REQ-AUD-001): same-transaction atomicity of state +
|
||||
* event + outbox, rollback leaving no residue, idempotent replay, snapshot
|
||||
* parent chains, events surviving target deletion, per-target ordering, and
|
||||
* the outbox claim/complete/release CAS. The schema-level constraints are
|
||||
* witnessed in packages/db/src/hierarchy-audit.witness.test.ts.
|
||||
*/
|
||||
describe('hierarchy audit repository integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let repo: HierarchyAuditRepository;
|
||||
|
||||
const input = (
|
||||
overrides: Partial<AppendHierarchyEventInput> = {},
|
||||
): AppendHierarchyEventInput => ({
|
||||
actorId: 'user-actor',
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
|
||||
correlationId: 'corr-1',
|
||||
idempotencyKey: `key-${randomUUID()}`,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-audit-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [HierarchyAuditRepository, { provide: DB, useValue: handle.db }],
|
||||
}).compile();
|
||||
repo = moduleRef.get(HierarchyAuditRepository);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('commits state, event, and outbox record atomically in one transaction', async () => {
|
||||
const companyId = randomUUID();
|
||||
const key = `key-${randomUUID()}`;
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Atomic Co', slug: 'atomic-co' });
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
const result = await repo.append(tx, {
|
||||
...input({ targetId: companyId, idempotencyKey: key }),
|
||||
targetSnapshot: { ...snapshot },
|
||||
});
|
||||
expect(result.replayed).toBe(false);
|
||||
expect(result.event.idempotencyKey).toBe(key);
|
||||
});
|
||||
const events = await handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
|
||||
expect(events).toHaveLength(1);
|
||||
const outbox = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.eventId, events[0]!.id));
|
||||
expect(outbox).toHaveLength(1);
|
||||
expect(outbox[0]).toMatchObject({
|
||||
status: 'pending',
|
||||
idempotencyKey: key,
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('a rolled-back transaction leaves no state, no event, and no outbox record', async () => {
|
||||
const companyId = randomUUID();
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Doomed Co', slug: 'doomed-co' });
|
||||
await repo.append(tx, input({ targetId: companyId, idempotencyKey: key }));
|
||||
throw new Error('deliberate rollback');
|
||||
}),
|
||||
).rejects.toThrow('deliberate rollback');
|
||||
const [companyRows, eventRows, outboxRows] = await Promise.all([
|
||||
handle.db.select().from(companies).where(eq(companies.id, companyId)),
|
||||
handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key)),
|
||||
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key)),
|
||||
]);
|
||||
expect(companyRows).toHaveLength(0);
|
||||
expect(eventRows).toHaveLength(0);
|
||||
expect(outboxRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('replays a duplicate idempotency key without inserting a second event or outbox record', async () => {
|
||||
const first = input();
|
||||
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(original.replayed).toBe(false);
|
||||
expect(replay.replayed).toBe(true);
|
||||
expect(replay.event.id).toBe(original.event.id);
|
||||
const outbox = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.eventId, original.event.id));
|
||||
expect(outbox).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws on a duplicate idempotency key carrying different event content', async () => {
|
||||
const first = input();
|
||||
await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) =>
|
||||
repo.append(tx, { ...first, verb: 'rename', targetId: randomUUID() }),
|
||||
),
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('throws on a duplicate idempotency key whose transfer destination differs', async () => {
|
||||
const from = { kind: 'company' as const, id: randomUUID(), slug: 'src-co' };
|
||||
const to = { kind: 'company' as const, id: randomUUID(), slug: 'dst-co' };
|
||||
const first = input({
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
transferFrom: from,
|
||||
transferTo: to,
|
||||
});
|
||||
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(original.replayed).toBe(false);
|
||||
// Identical retry replays; a retry re-routed to a different destination must conflict.
|
||||
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(replay.replayed).toBe(true);
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) =>
|
||||
repo.append(tx, { ...first, transferTo: { ...to, id: randomUUID() } }),
|
||||
),
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('builds root-first parent chains and rejects unknown nodes', async () => {
|
||||
const companyId = randomUUID();
|
||||
const estateId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Chain Co', slug: 'chain-co' });
|
||||
await tx
|
||||
.insert(estates)
|
||||
.values({ id: estateId, name: 'Chain Estate', slug: 'chain-estate', companyId });
|
||||
await tx
|
||||
.insert(platformProjects)
|
||||
.values({ id: projectId, name: 'Chain Project', slug: 'chain-project', estateId });
|
||||
});
|
||||
const snapshot = await repo.snapshot(handle.db, 'platform_project', projectId);
|
||||
expect(snapshot).toMatchObject({ id: projectId, slug: 'chain-project', name: 'Chain Project' });
|
||||
expect(snapshot.parentChain).toEqual([
|
||||
{ kind: 'company', id: companyId, slug: 'chain-co' },
|
||||
{ kind: 'estate', id: estateId, slug: 'chain-estate' },
|
||||
]);
|
||||
await expect(repo.snapshot(handle.db, 'estate', randomUUID())).rejects.toThrow(
|
||||
HierarchyNodeNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps events readable, in per-target seq order, after the target row is deleted', async () => {
|
||||
const companyId = randomUUID();
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Mortal Co', slug: 'mortal-co' });
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
await repo.append(tx, input({ targetId: companyId, targetSnapshot: { ...snapshot } }));
|
||||
});
|
||||
await handle.db.transaction(async (tx) => {
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
await repo.append(tx, {
|
||||
...input({ verb: 'delete', targetId: companyId }),
|
||||
targetSnapshot: { ...snapshot },
|
||||
});
|
||||
await tx.delete(companies).where(eq(companies.id, companyId));
|
||||
});
|
||||
const events = await repo.eventsForTarget(companyId);
|
||||
expect(events.map((e) => e.verb)).toEqual(['create', 'delete']);
|
||||
expect(events[1]!.seq).toBeGreaterThan(events[0]!.seq);
|
||||
expect((events[1]!.targetSnapshot as { id: string }).id).toBe(companyId);
|
||||
});
|
||||
|
||||
it('claims the oldest pending outbox record exactly once, completes and releases by CAS', async () => {
|
||||
// Drain records left pending by earlier cases so ordering is deterministic.
|
||||
for (;;) {
|
||||
const drained = await repo.claimPendingOutbox();
|
||||
if (!drained) break;
|
||||
await repo.completeOutbox(drained.id);
|
||||
}
|
||||
const older = await handle.db.transaction(async (tx) => repo.append(tx, input()));
|
||||
const newer = await handle.db.transaction(async (tx) => repo.append(tx, input()));
|
||||
|
||||
const claimed = await repo.claimPendingOutbox();
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.eventId).toBe(older.event.id);
|
||||
expect(claimed!.status).toBe('processing');
|
||||
|
||||
// Delivery fails: release returns it to pending and it is claimable again.
|
||||
await repo.releaseOutbox(claimed!.id);
|
||||
const reclaimed = await repo.claimPendingOutbox();
|
||||
expect(reclaimed!.id).toBe(claimed!.id);
|
||||
|
||||
await repo.completeOutbox(reclaimed!.id);
|
||||
const done = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.id, reclaimed!.id));
|
||||
expect(done[0]!.status).toBe('delivered');
|
||||
expect(done[0]!.deliveredAt).not.toBeNull();
|
||||
// completeOutbox is CAS-guarded on 'processing': completing again is a no-op.
|
||||
await repo.completeOutbox(reclaimed!.id);
|
||||
|
||||
const second = await repo.claimPendingOutbox();
|
||||
expect(second!.eventId).toBe(newer.event.id);
|
||||
await repo.completeOutbox(second!.id);
|
||||
expect(await repo.claimPendingOutbox()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,274 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyOutbox,
|
||||
platformProjects,
|
||||
type Db,
|
||||
type HIERARCHY_AUDIT_TARGET_KINDS,
|
||||
type HIERARCHY_AUDIT_VERBS,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy audit event + outbox machinery (contract 1 §5.2).
|
||||
*
|
||||
* Every hierarchy mutation writes its semantic audit event AND the event's
|
||||
* outbox record on the caller's transaction, so state, event, and outbox
|
||||
* commit or roll back together. Events reference their target by an
|
||||
* immutable snapshot (id, slug, parent chain at event time), never by a
|
||||
* foreign key into the class tables — append-only events survive the
|
||||
* deletion of their target. This module exposes no update or delete path
|
||||
* for events: append-only is a property of the code surface, witnessed by
|
||||
* the integration tests.
|
||||
*
|
||||
* This is NOT a class-table writer: it touches only the audit/outbox
|
||||
* tables, so it does not appear on the writer-coverage allowlist. The
|
||||
* hierarchy command repository (HierarchyRepository) is the allowlisted
|
||||
* writer and calls into this on its own transactions.
|
||||
*/
|
||||
|
||||
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
|
||||
export type HierarchyTargetKind = (typeof HIERARCHY_AUDIT_TARGET_KINDS)[number];
|
||||
export type HierarchyNodeKind = Exclude<HierarchyTargetKind, 'grant'>;
|
||||
|
||||
export interface ParentChainEntry {
|
||||
readonly kind: HierarchyNodeKind;
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
/** Immutable node snapshot at event time; parentChain is root-first. */
|
||||
export interface HierarchyNodeSnapshot {
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
readonly parentChain: readonly ParentChainEntry[];
|
||||
}
|
||||
|
||||
export interface AppendHierarchyEventInput {
|
||||
readonly actorId: string;
|
||||
readonly verb: HierarchyAuditVerb;
|
||||
readonly targetKind: HierarchyTargetKind;
|
||||
readonly targetId: string;
|
||||
/** Node events: HierarchyNodeSnapshot. Grant events: subject/target/role snapshot (contract 2 §4.4). */
|
||||
readonly targetSnapshot: Record<string, unknown>;
|
||||
/** Present exactly on transfers (CHECK-enforced): source/destination parent { kind, id, slug }. */
|
||||
readonly transferFrom?: ParentChainEntry;
|
||||
readonly transferTo?: ParentChainEntry;
|
||||
readonly correlationId: string;
|
||||
/** Prior event in the causal chain (e.g. the delete event causing cascaded grant_revoke events). */
|
||||
readonly causationId?: string;
|
||||
readonly idempotencyKey: string;
|
||||
}
|
||||
|
||||
export type HierarchyAuditEventRow = typeof hierarchyAuditEvents.$inferSelect;
|
||||
export type HierarchyOutboxRow = typeof hierarchyOutbox.$inferSelect;
|
||||
|
||||
export interface AppendHierarchyEventResult {
|
||||
readonly event: HierarchyAuditEventRow;
|
||||
/** True when the idempotency key had already committed an identical event (REQ-AUD-001 duplicate suppression). */
|
||||
readonly replayed: boolean;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select'>;
|
||||
|
||||
export class HierarchyAuditIdempotencyConflictError extends Error {
|
||||
constructor(idempotencyKey: string) {
|
||||
super(
|
||||
`hierarchy audit idempotency key ${idempotencyKey} already exists with different event content`,
|
||||
);
|
||||
this.name = 'HierarchyAuditIdempotencyConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class HierarchyNodeNotFoundError extends Error {
|
||||
constructor(kind: HierarchyNodeKind, id: string) {
|
||||
super(`hierarchy node not found: ${kind} ${id}`);
|
||||
this.name = 'HierarchyNodeNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one audit event and its outbox record on the caller's transaction.
|
||||
* A duplicate idempotency key with identical semantic content returns the
|
||||
* prior event (replayed: true) without inserting anything; a duplicate key
|
||||
* with different content throws.
|
||||
*/
|
||||
export async function appendHierarchyEvent(
|
||||
tx: Tx,
|
||||
input: AppendHierarchyEventInput,
|
||||
): Promise<AppendHierarchyEventResult> {
|
||||
const inserted = await tx
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values({
|
||||
actorId: input.actorId,
|
||||
verb: input.verb,
|
||||
targetKind: input.targetKind,
|
||||
targetId: input.targetId,
|
||||
targetSnapshot: input.targetSnapshot,
|
||||
transferFrom: input.transferFrom ?? null,
|
||||
transferTo: input.transferTo ?? null,
|
||||
correlationId: input.correlationId,
|
||||
causationId: input.causationId ?? null,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const event = inserted[0];
|
||||
if (event) {
|
||||
await tx.insert(hierarchyOutbox).values({
|
||||
eventId: event.id,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
correlationId: input.correlationId,
|
||||
});
|
||||
return { event, replayed: false };
|
||||
}
|
||||
|
||||
const prior = await tx
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, input.idempotencyKey))
|
||||
.limit(1);
|
||||
const existing = prior[0];
|
||||
if (!existing || !sameEvent(existing, input)) {
|
||||
throw new HierarchyAuditIdempotencyConflictError(input.idempotencyKey);
|
||||
}
|
||||
// Event and outbox committed atomically the first time, so the outbox
|
||||
// record already exists; a replay inserts nothing.
|
||||
return { event: existing, replayed: true };
|
||||
}
|
||||
|
||||
/** Key-order-independent serialization: jsonb does not preserve key order. */
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
const body = Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',');
|
||||
return `{${body}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function sameEvent(row: HierarchyAuditEventRow, input: AppendHierarchyEventInput): boolean {
|
||||
return (
|
||||
row.actorId === input.actorId &&
|
||||
row.verb === input.verb &&
|
||||
row.targetKind === input.targetKind &&
|
||||
row.targetId === input.targetId &&
|
||||
row.correlationId === input.correlationId &&
|
||||
(row.causationId ?? null) === (input.causationId ?? null) &&
|
||||
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot) &&
|
||||
// Transfer source/destination are semantic content (§5.2): a retry with a
|
||||
// different destination must conflict, never silently replay.
|
||||
canonicalJson(row.transferFrom ?? null) === canonicalJson(input.transferFrom ?? null) &&
|
||||
canonicalJson(row.transferTo ?? null) === canonicalJson(input.transferTo ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable snapshot for a node: its row plus the parent chain up
|
||||
* to the company root, root-first, read on the caller's transaction so the
|
||||
* snapshot is consistent with the mutation it audits.
|
||||
*/
|
||||
export async function buildNodeSnapshot(
|
||||
tx: Tx,
|
||||
kind: HierarchyNodeKind,
|
||||
id: string,
|
||||
): Promise<HierarchyNodeSnapshot> {
|
||||
if (kind === 'company') {
|
||||
const rows = await tx.select().from(companies).where(eq(companies.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
return { id: row.id, slug: row.slug, name: row.name, parentChain: [] };
|
||||
}
|
||||
if (kind === 'estate') {
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
const parent = await buildNodeSnapshot(tx, 'company', row.companyId);
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
parentChain: [...parent.parentChain, { kind: 'company', id: parent.id, slug: parent.slug }],
|
||||
};
|
||||
}
|
||||
const rows = await tx.select().from(platformProjects).where(eq(platformProjects.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
const parent = await buildNodeSnapshot(tx, 'estate', row.estateId);
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
parentChain: [...parent.parentChain, { kind: 'estate', id: parent.id, slug: parent.slug }],
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyAuditRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/** Compose an event+outbox append into a caller-owned transaction. */
|
||||
append(tx: Tx, input: AppendHierarchyEventInput): Promise<AppendHierarchyEventResult> {
|
||||
return appendHierarchyEvent(tx, input);
|
||||
}
|
||||
|
||||
snapshot(tx: Tx, kind: HierarchyNodeKind, id: string): Promise<HierarchyNodeSnapshot> {
|
||||
return buildNodeSnapshot(tx, kind, id);
|
||||
}
|
||||
|
||||
/** Per-target ordered event history (REQ-AUD-001 per-target ordering; read-only). */
|
||||
async eventsForTarget(targetId: string): Promise<HierarchyAuditEventRow[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.targetId, targetId))
|
||||
.orderBy(asc(hierarchyAuditEvents.seq));
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the oldest pending outbox record (claim-by-CAS: the UPDATE is
|
||||
* guarded on status so a lost race returns null and the caller retries).
|
||||
*/
|
||||
async claimPendingOutbox(): Promise<HierarchyOutboxRow | null> {
|
||||
const candidates = await this.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.status, 'pending'))
|
||||
.orderBy(asc(hierarchyOutbox.createdAt))
|
||||
.limit(1);
|
||||
const candidate = candidates[0];
|
||||
if (!candidate) return null;
|
||||
const claimed = await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(and(eq(hierarchyOutbox.id, candidate.id), eq(hierarchyOutbox.status, 'pending')))
|
||||
.returning();
|
||||
return claimed[0] ?? null;
|
||||
}
|
||||
|
||||
async completeOutbox(id: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'delivered', deliveredAt: now, updatedAt: now })
|
||||
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
|
||||
}
|
||||
|
||||
/** Return a claimed record to pending (delivery failed; it stays replayable). */
|
||||
async releaseOutbox(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
|
||||
}
|
||||
}
|
||||
@@ -1,965 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
companies,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyGrants,
|
||||
hierarchyOutbox,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
workspaces,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { appendHierarchyEvent } from './hierarchy-audit.repository.js';
|
||||
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
|
||||
import { HierarchyRepository, type HierarchyResult } from './hierarchy.repository.js';
|
||||
|
||||
/**
|
||||
* Command-level witnesses for the hierarchy command family (M4-1b-ii):
|
||||
* contract 1 §6.4 (per-mutation-class commit + rollback), §6.5
|
||||
* (authorization outcomes), §6.7 (no existence oracle), §6.9 (visibility),
|
||||
* and contract 2 §3 grant-evaluation semantics (deny-by-default,
|
||||
* ancestor-chain inheritance, max-role, live revocation, suspended team
|
||||
* subjects). Schema-level constraints are witnessed in
|
||||
* packages/db/src/hierarchy-schema.witness.test.ts; the audit machinery's
|
||||
* own atomicity in hierarchy-audit.integration.test.ts.
|
||||
*
|
||||
* The rollback legs pre-seed an audit event under the command's idempotency
|
||||
* key with different content: the command's append then throws inside the
|
||||
* command transaction, so the whole mutation must roll back — the command
|
||||
* returns `conflict` and leaves no state change, no second event, and no
|
||||
* second outbox record.
|
||||
*/
|
||||
describe('hierarchy commands integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let repo: HierarchyRepository;
|
||||
let evaluation: HierarchyGrantEvaluationService;
|
||||
|
||||
const OWNER = 'hier-cmd-owner';
|
||||
const ADMIN = 'hier-cmd-admin';
|
||||
const STRANGER = 'hier-cmd-stranger';
|
||||
const SUBJECT = 'hier-cmd-subject';
|
||||
|
||||
/** Base fixture: OWNER's company (created through the command surface). */
|
||||
let companyId: string;
|
||||
|
||||
const slug = (prefix: string): string => `${prefix}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
function expectOk<T>(result: HierarchyResult<T>): { ok: true } & T {
|
||||
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const eventsForKey = (key: string) =>
|
||||
handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
|
||||
|
||||
const outboxForKey = (key: string) =>
|
||||
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key));
|
||||
|
||||
/** Occupy `key` with unrelated event content so a command reusing it must abort. */
|
||||
const seedConflictingKey = async (key: string): Promise<void> => {
|
||||
await handle.db.transaction(async (tx) =>
|
||||
appendHierarchyEvent(tx, {
|
||||
actorId: 'seed-actor',
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { seeded: true },
|
||||
correlationId: 'seed-correlation',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* §6.4 rollback leg: the command must return `conflict` and leave exactly
|
||||
* the seeded event/outbox pair under the key — nothing it wrote survives.
|
||||
*/
|
||||
const expectRolledBack = async <T>(
|
||||
key: string,
|
||||
command: () => Promise<HierarchyResult<T>>,
|
||||
assertUnchanged: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
await seedConflictingKey(key);
|
||||
const result = await command();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe('conflict');
|
||||
expect(await eventsForKey(key)).toHaveLength(1);
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
await assertUnchanged();
|
||||
};
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-commands-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
HierarchyRepository,
|
||||
HierarchyGrantEvaluationService,
|
||||
{ provide: DB, useValue: handle.db },
|
||||
],
|
||||
}).compile();
|
||||
repo = moduleRef.get(HierarchyRepository);
|
||||
evaluation = moduleRef.get(HierarchyGrantEvaluationService);
|
||||
|
||||
await handle.db.insert(users).values([
|
||||
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
|
||||
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
|
||||
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
|
||||
{ id: SUBJECT, name: 'Subject', email: `${SUBJECT}@example.com` },
|
||||
]);
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Base Co', slug: slug('base') }),
|
||||
);
|
||||
companyId = created.company.id;
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── §6.4 commit legs ───────────────────────────────────────────────────────
|
||||
|
||||
it('createCompany commits company, owner grant, causation-linked events, and outbox atomically', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
const result = expectOk(
|
||||
await repo.createCompany({
|
||||
actorId: OWNER,
|
||||
name: 'Atomic Co',
|
||||
slug: slug('atomic'),
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
expect(result.company.visibility).toBe('private');
|
||||
expect(result.grant.role).toBe('hierarchy:owner');
|
||||
expect(result.grant.userId).toBe(OWNER);
|
||||
expect(result.grant.grantedBy).toBe(OWNER);
|
||||
|
||||
const [createEvents, grantEvents] = await Promise.all([
|
||||
eventsForKey(key),
|
||||
eventsForKey(`${key}:grant`),
|
||||
]);
|
||||
expect(createEvents).toHaveLength(1);
|
||||
expect(createEvents[0]).toMatchObject({ verb: 'create', targetId: result.company.id });
|
||||
expect(grantEvents).toHaveLength(1);
|
||||
expect(grantEvents[0]).toMatchObject({ verb: 'grant_create', targetId: result.grant.id });
|
||||
// The grant event is caused by the create event, same correlation (§4.3).
|
||||
expect(grantEvents[0]!.causationId).toBe(createEvents[0]!.id);
|
||||
expect(grantEvents[0]!.correlationId).toBe(createEvents[0]!.correlationId);
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
expect(await outboxForKey(`${key}:grant`)).toHaveLength(1);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, result.company.id));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deleteCompany commits the delete with one audited grant_revoke per cascaded grant', async () => {
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Mortal Co', slug: slug('mortal') }),
|
||||
);
|
||||
const extraGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: created.company.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.deleteCompany({
|
||||
actorId: OWNER,
|
||||
companyId: created.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
const deleteEvents = await eventsForKey(key);
|
||||
expect(deleteEvents).toHaveLength(1);
|
||||
expect(deleteEvents[0]).toMatchObject({ verb: 'delete', targetId: created.company.id });
|
||||
for (const grantId of [created.grant.id, extraGrant.grant.id]) {
|
||||
const revokeEvents = await eventsForKey(`${key}:revoke:${grantId}`);
|
||||
expect(revokeEvents).toHaveLength(1);
|
||||
expect(revokeEvents[0]).toMatchObject({ verb: 'grant_revoke', targetId: grantId });
|
||||
expect(revokeEvents[0]!.causationId).toBe(deleteEvents[0]!.id);
|
||||
}
|
||||
expect(
|
||||
await handle.db.select().from(companies).where(eq(companies.id, created.company.id)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── §6.4 rollback legs (one per mutation class) ────────────────────────────
|
||||
|
||||
it('renameCompany commits the rename with an audited event carrying previousName', async () => {
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Old Name Co', slug: slug('rename') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
const renamed = expectOk(
|
||||
await repo.renameCompany({
|
||||
actorId: OWNER,
|
||||
companyId: created.company.id,
|
||||
name: 'New Name Co',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
expect(renamed.company.name).toBe('New Name Co');
|
||||
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'rename', targetId: created.company.id });
|
||||
// §6.4: the audited rename carries the old and new names.
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
name: 'New Name Co',
|
||||
previousName: 'Old Name Co',
|
||||
});
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, created.company.id));
|
||||
expect(rows[0]!.name).toBe('New Name Co');
|
||||
});
|
||||
|
||||
it('revokeGrant commits the row deletion with one audited grant_revoke event', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
const revoked = expectOk(
|
||||
await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: key }),
|
||||
);
|
||||
expect(revoked.revokedId).toBe(grant.grant.id);
|
||||
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'grant_revoke', targetId: grant.grant.id });
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
|
||||
// §6 revocation = row deletion: the grant row is gone.
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id));
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rolls back a create: no estate row survives the aborted transaction', async () => {
|
||||
const estateSlug = slug('rb-create');
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
name: 'Doomed Estate',
|
||||
slug: estateSlug,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db.select().from(estates).where(eq(estates.slug, estateSlug)),
|
||||
).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a rename: the company keeps its name', async () => {
|
||||
const before = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() => repo.renameCompany({ actorId: OWNER, companyId, name: 'Never', idempotencyKey: key }),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
expect(after.name).toBe(before.name);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a visibility change: the company stays private', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId,
|
||||
visibility: 'directory',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
expect(after.visibility).toBe('private');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a transfer: the estate keeps its parent', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-T', slug: slug('rb-t') }),
|
||||
);
|
||||
const other = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'RB Dest', slug: slug('rb-dest') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: other.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
|
||||
)[0]!;
|
||||
expect(after.companyId).toBe(companyId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a delete: the estate row survives', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-D', slug: slug('rb-d') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() => repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id, idempotencyKey: key }),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id)),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a grant create: no grant row survives', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: STRANGER,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.userId, STRANGER));
|
||||
expect(rows.filter((r) => r.companyId === companyId)).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a grant change and a grant revoke: the grant keeps its role and its row', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const changeKey = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
changeKey,
|
||||
() =>
|
||||
repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'member',
|
||||
idempotencyKey: changeKey,
|
||||
}),
|
||||
async () => {
|
||||
const row = (
|
||||
await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id))
|
||||
)[0]!;
|
||||
expect(row.role).toBe('viewer');
|
||||
},
|
||||
);
|
||||
const revokeKey = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
revokeKey,
|
||||
() =>
|
||||
repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: revokeKey }),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id)),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
|
||||
});
|
||||
|
||||
it('replays a completed command idempotently through the audit machinery', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
const input = { actorId: OWNER, companyId, name: 'Replayed Estate', slug: slug('replay') };
|
||||
const first = expectOk(await repo.createEstate({ ...input, idempotencyKey: key }));
|
||||
// The retry's insert no-ops on the slug conflict — the command surfaces
|
||||
// `conflict`, and crucially appends no second event under the key.
|
||||
const retry = await repo.createEstate({ ...input, idempotencyKey: key });
|
||||
expect(retry.ok).toBe(false);
|
||||
expect(await eventsForKey(key)).toHaveLength(1);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: first.estate.id }));
|
||||
});
|
||||
|
||||
// ── §6.5 authorization ─────────────────────────────────────────────────────
|
||||
|
||||
it('deny-by-default: a user with no grant cannot mutate and sees not_found (§3.1)', async () => {
|
||||
expect(await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
expect(
|
||||
await repo.createEstate({ actorId: STRANGER, companyId, name: 'x', slug: slug('deny') }),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.deleteCompany({ actorId: STRANGER, companyId })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('grant management requires effective owner: member and viewer are refused (§4.1)', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'member',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await repo.createGrant({
|
||||
actorId: SUBJECT,
|
||||
userId: STRANGER,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.revokeGrant({ actorId: SUBJECT, grantId: grant.grant.id })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
// Member also cannot create children (owner-only, §4.1/§4.3).
|
||||
expect(
|
||||
await repo.createEstate({ actorId: SUBJECT, companyId, name: 'x', slug: slug('member') }),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
|
||||
});
|
||||
|
||||
it('platform admin confers no tenant content access (§1.1): ungrated admin is a stranger', async () => {
|
||||
expect(await repo.renameCompany({ actorId: ADMIN, companyId, name: 'x' })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
expect(
|
||||
await repo.createGrant({
|
||||
actorId: ADMIN,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.listGrantedCompanies(ADMIN)).toEqual([]);
|
||||
expect(await evaluation.effectiveRole(ADMIN, 'company', companyId)).toBeNull();
|
||||
});
|
||||
|
||||
it('visibility change is platform-admin-only (§5.5): the owner is forbidden, the admin succeeds', async () => {
|
||||
const owned = await repo.changeCompanyVisibility({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
visibility: 'directory',
|
||||
});
|
||||
expect(owned).toEqual({
|
||||
ok: false,
|
||||
error: 'forbidden',
|
||||
message: 'visibility change is platform-admin-only',
|
||||
});
|
||||
const changed = expectOk(
|
||||
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'directory' }),
|
||||
);
|
||||
expect(changed.company.visibility).toBe('directory');
|
||||
// Restore for later witnesses.
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'private' }),
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.9 visibility ────────────────────────────────────────────────────────
|
||||
|
||||
it('directory lists exactly directory-class companies with closed fields (§2.8)', async () => {
|
||||
const listed = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Listed Co', slug: slug('listed') }),
|
||||
);
|
||||
const unlisted = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Unlisted Co', slug: slug('unlisted') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId: listed.company.id,
|
||||
visibility: 'directory',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
|
||||
const directory = await repo.listDirectory();
|
||||
const ids = directory.map((entry) => entry.id);
|
||||
expect(ids).toContain(listed.company.id);
|
||||
expect(ids).not.toContain(unlisted.company.id);
|
||||
expect(ids).not.toContain(companyId);
|
||||
// Closed-field: existence, name, slug — nothing else (no visibility, no
|
||||
// timestamps, no grant or membership data).
|
||||
for (const entry of directory) {
|
||||
expect(Object.keys(entry).sort()).toEqual(['id', 'name', 'slug']);
|
||||
}
|
||||
|
||||
// §5.5: the audited event carries old and new values.
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'visibility_change', targetId: listed.company.id });
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
previousVisibility: 'private',
|
||||
visibility: 'directory',
|
||||
});
|
||||
});
|
||||
|
||||
it('directory disclosure confers no authority: a listed company still refuses non-granted callers (§6.9)', async () => {
|
||||
const listed = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Exposed Co', slug: slug('exposed') }),
|
||||
);
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId: listed.company.id,
|
||||
visibility: 'directory',
|
||||
}),
|
||||
);
|
||||
|
||||
// The company is directory-listed for the whole probe window...
|
||||
expect((await repo.listDirectory()).map((entry) => entry.id)).toContain(listed.company.id);
|
||||
|
||||
// ...but the non-granted reader's granted-read surface still excludes it:
|
||||
// directory disclosure adds existence/name/slug only, never content access.
|
||||
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
|
||||
|
||||
// A stranger mutation of the listed company is refused exactly like a
|
||||
// missing node — the §6.7 carve-out covers the listing, not commands.
|
||||
const realProbe = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: listed.company.id,
|
||||
name: 'x',
|
||||
});
|
||||
const missingProbe = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: randomUUID(),
|
||||
name: 'x',
|
||||
});
|
||||
expect(realProbe).toEqual(missingProbe);
|
||||
expect(await repo.deleteCompany({ actorId: STRANGER, companyId: listed.company.id })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('granted companies are the reader control: owner sees them, a stranger sees nothing (§2.8)', async () => {
|
||||
const ownerCompanies = await repo.listGrantedCompanies(OWNER);
|
||||
expect(ownerCompanies.map((c) => c.id)).toContain(companyId);
|
||||
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
|
||||
});
|
||||
|
||||
// ── §6.7 no existence oracle ───────────────────────────────────────────────
|
||||
|
||||
it('an unauthorized probe of a real node is indistinguishable from a missing node', async () => {
|
||||
const realCompany = await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' });
|
||||
const missingCompany = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: randomUUID(),
|
||||
name: 'x',
|
||||
});
|
||||
expect(realCompany).toEqual(missingCompany);
|
||||
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
name: 'Oracle E',
|
||||
slug: slug('oracle'),
|
||||
}),
|
||||
);
|
||||
const realEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: estate.estate.id });
|
||||
const missingEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: randomUUID() });
|
||||
expect(realEstate).toEqual(missingEstate);
|
||||
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'estate',
|
||||
targetId: estate.estate.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const realGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: grant.grant.id });
|
||||
const missingGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: randomUUID() });
|
||||
expect(realGrant).toEqual(missingGrant);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
|
||||
});
|
||||
|
||||
// ── contract 2 §3 grant evaluation ─────────────────────────────────────────
|
||||
|
||||
it('a company grant confers its role down the whole chain, workspace included (§3.2)', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'Chain E', slug: slug('chain') }),
|
||||
);
|
||||
const project = expectOk(
|
||||
await repo.createPlatformProject({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
name: 'Chain P',
|
||||
slug: slug('chain-p'),
|
||||
}),
|
||||
);
|
||||
// Workspaces are evaluable but not hierarchy commands; seed one directly.
|
||||
const workspaceId = randomUUID();
|
||||
await handle.db.insert(workspaces).values({
|
||||
id: workspaceId,
|
||||
name: 'Chain W',
|
||||
slug: slug('chain-w'),
|
||||
platformProjectId: project.platformProject.id,
|
||||
});
|
||||
|
||||
for (const [kind, id] of [
|
||||
['company', companyId],
|
||||
['estate', estate.estate.id],
|
||||
['platform_project', project.platformProject.id],
|
||||
['workspace', workspaceId],
|
||||
] as const) {
|
||||
expect(await evaluation.effectiveRole(OWNER, kind, id)).toBe('owner');
|
||||
expect(await evaluation.effectiveRole(STRANGER, kind, id)).toBeNull();
|
||||
}
|
||||
|
||||
// Max-role (§3.3): viewer on the company + owner on the estate → owner at
|
||||
// and below the estate, viewer at the company.
|
||||
const viewerGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const ownerGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'estate',
|
||||
targetId: estate.estate.id,
|
||||
role: 'owner',
|
||||
}),
|
||||
);
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBe('viewer');
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('owner');
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'workspace', workspaceId)).toBe('owner');
|
||||
|
||||
// Revocation is row deletion and denies the very next evaluation (§6).
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: ownerGrant.grant.id }));
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('viewer');
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: viewerGrant.grant.id }));
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
|
||||
|
||||
await handle.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
|
||||
expectOk(
|
||||
await repo.deletePlatformProject({
|
||||
actorId: OWNER,
|
||||
platformProjectId: project.platformProject.id,
|
||||
}),
|
||||
);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
|
||||
});
|
||||
|
||||
it('team grant subjects are suspended: a team row confers nothing and cannot be changed (§1.4)', async () => {
|
||||
const teamId = randomUUID();
|
||||
await handle.db.insert(teams).values({
|
||||
id: teamId,
|
||||
name: slug('team'),
|
||||
slug: slug('team'),
|
||||
ownerId: SUBJECT,
|
||||
managerId: SUBJECT,
|
||||
});
|
||||
// Out-of-band team row (the command surface cannot create one).
|
||||
const inserted = await handle.db
|
||||
.insert(hierarchyGrants)
|
||||
.values({ teamId, companyId, role: 'owner', grantedBy: OWNER })
|
||||
.returning();
|
||||
const teamGrantId = inserted[0]!.id;
|
||||
|
||||
// The team's own owner gains no effective role from it.
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
|
||||
// changeGrant refuses the row.
|
||||
expect(
|
||||
await repo.changeGrant({ actorId: OWNER, grantId: teamGrantId, role: 'viewer' }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: 'conflict',
|
||||
message: 'team grant subjects are suspended',
|
||||
});
|
||||
await handle.db.delete(hierarchyGrants).where(eq(hierarchyGrants.id, teamGrantId));
|
||||
await handle.db.delete(teams).where(eq(teams.id, teamId));
|
||||
});
|
||||
|
||||
// ── command conflict semantics ─────────────────────────────────────────────
|
||||
|
||||
it('transfer needs owner on both parents in its own transaction, and refuses no-op and colliding transfers (§5)', async () => {
|
||||
const source = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Src Co', slug: slug('src') }),
|
||||
);
|
||||
const destination = expectOk(
|
||||
await repo.createCompany({ actorId: SUBJECT, name: 'Dst Co', slug: slug('dst') }),
|
||||
);
|
||||
const estateSlug = slug('mv');
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: source.company.id,
|
||||
name: 'Mv E',
|
||||
slug: estateSlug,
|
||||
}),
|
||||
);
|
||||
|
||||
// OWNER owns the source but not the destination → not_found (§6.7-safe).
|
||||
expect(
|
||||
await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: destination.company.id,
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
|
||||
// Same-parent transfer is refused.
|
||||
const samePlace = await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: source.company.id,
|
||||
});
|
||||
expect(samePlace.ok).toBe(false);
|
||||
if (!samePlace.ok) expect(samePlace.error).toBe('conflict');
|
||||
|
||||
// Grant OWNER the destination; a slug collision there is refused.
|
||||
expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: SUBJECT,
|
||||
userId: OWNER,
|
||||
targetKind: 'company',
|
||||
targetId: destination.company.id,
|
||||
role: 'owner',
|
||||
}),
|
||||
);
|
||||
expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: destination.company.id,
|
||||
name: 'Collide',
|
||||
slug: estateSlug,
|
||||
}),
|
||||
);
|
||||
const collision = await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: destination.company.id,
|
||||
});
|
||||
expect(collision.ok).toBe(false);
|
||||
if (!collision.ok) expect(collision.error).toBe('conflict');
|
||||
});
|
||||
|
||||
it('a successful transfer records transfer_from and transfer_to (§6.4 three-leg witness)', async () => {
|
||||
const from = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'From Co', slug: slug('from') }),
|
||||
);
|
||||
const to = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'To Co', slug: slug('to') }),
|
||||
);
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: from.company.id,
|
||||
name: 'Moved E',
|
||||
slug: slug('moved'),
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: to.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
const moved = (
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
|
||||
)[0]!;
|
||||
expect(moved.companyId).toBe(to.company.id);
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'transfer', targetId: estate.estate.id });
|
||||
expect(events[0]!.transferFrom).toMatchObject({ kind: 'company', id: from.company.id });
|
||||
expect(events[0]!.transferTo).toMatchObject({ kind: 'company', id: to.company.id });
|
||||
// The post-transfer snapshot's parent chain names the destination.
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
parentChain: [{ kind: 'company', id: to.company.id, slug: to.company.slug }],
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses duplicate slugs, deletes with children, and degenerate grant commands as conflicts', async () => {
|
||||
const co = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Conflict Co', slug: slug('conf') }),
|
||||
);
|
||||
const dupSlug = await repo.createCompany({ actorId: OWNER, name: 'x', slug: co.company.slug });
|
||||
expect(dupSlug.ok).toBe(false);
|
||||
if (!dupSlug.ok) expect(dupSlug.error).toBe('conflict');
|
||||
|
||||
expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: co.company.id,
|
||||
name: 'Child',
|
||||
slug: slug('child'),
|
||||
}),
|
||||
);
|
||||
const withChildren = await repo.deleteCompany({ actorId: OWNER, companyId: co.company.id });
|
||||
expect(withChildren.ok).toBe(false);
|
||||
if (!withChildren.ok) expect(withChildren.error).toBe('conflict');
|
||||
|
||||
// Grant to a nonexistent subject is refused (the caller already holds
|
||||
// owner, so the refusal discloses nothing new).
|
||||
const ghost = await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: `missing-${randomUUID()}`,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(ghost.ok).toBe(false);
|
||||
if (!ghost.ok) expect(ghost.error).toBe('conflict');
|
||||
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const duplicate = await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(duplicate.ok).toBe(false);
|
||||
if (!duplicate.ok) expect(duplicate.error).toBe('conflict');
|
||||
|
||||
const sameRole = await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(sameRole.ok).toBe(false);
|
||||
if (!sameRole.ok) expect(sameRole.error).toBe('conflict');
|
||||
|
||||
// A second grant with another role exists → changing the first onto that
|
||||
// role would collide with the unique constraint; refused ahead of it.
|
||||
const second = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'member',
|
||||
}),
|
||||
);
|
||||
const collide = await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'member',
|
||||
});
|
||||
expect(collide.ok).toBe(false);
|
||||
if (!collide.ok) expect(collide.error).toBe('conflict');
|
||||
|
||||
// A clean change succeeds and records the previous role, namespaced (§4.5).
|
||||
const changeKey = `key-${randomUUID()}`;
|
||||
const changed = expectOk(
|
||||
await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: second.grant.id,
|
||||
role: 'owner',
|
||||
idempotencyKey: changeKey,
|
||||
}),
|
||||
);
|
||||
expect(changed.grant.role).toBe('hierarchy:owner');
|
||||
const events = await eventsForKey(changeKey);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
role: 'hierarchy:owner',
|
||||
previousRole: 'hierarchy:member',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
inArray,
|
||||
or,
|
||||
platformProjects,
|
||||
workspaces,
|
||||
and,
|
||||
type Db,
|
||||
HIERARCHY_GRANT_ROLES,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy grant evaluation (contract 2 §3).
|
||||
*
|
||||
* Deny-by-default (§3.1): a user's effective role on a node is null unless a
|
||||
* grant row explicitly confers one. Grants apply down the chain only (§3.2):
|
||||
* the effective role on a node is the maximum role over grants targeting the
|
||||
* node itself or any of its ancestors, maximum per the total order
|
||||
* viewer ⊂ member ⊂ owner (§2). Evaluation is live and per-decision — no
|
||||
* caching — so revocation (row deletion, §6) denies the next decision
|
||||
* inherently. A missing node evaluates to null, indistinguishable from
|
||||
* no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7).
|
||||
*
|
||||
* Team grant subjects are SUSPENDED (§1.4): the command surface refuses to
|
||||
* create them and this evaluator considers user-subject grants only, so a
|
||||
* team row could not confer access even if one existed.
|
||||
*
|
||||
* Read-only module: it selects from the class tables but never writes them,
|
||||
* so it does not appear on the writer-coverage allowlist.
|
||||
*/
|
||||
|
||||
export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
/** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */
|
||||
export type GrantTargetKind = 'company' | 'estate' | 'platform_project';
|
||||
/** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */
|
||||
export type EvaluableNodeKind = GrantTargetKind | 'workspace';
|
||||
|
||||
type Tx = Pick<Db, 'select'>;
|
||||
|
||||
/** Ancestor chain of a node, self included at its own level; ids only. */
|
||||
export interface AncestorChain {
|
||||
readonly companyId: string;
|
||||
readonly estateId?: string;
|
||||
readonly platformProjectId?: string;
|
||||
readonly workspaceId?: string;
|
||||
}
|
||||
|
||||
export function roleStrength(role: HierarchyGrantRole): number {
|
||||
return HIERARCHY_GRANT_ROLES.indexOf(role);
|
||||
}
|
||||
|
||||
export function roleAtLeast(
|
||||
role: HierarchyGrantRole | null,
|
||||
required: HierarchyGrantRole,
|
||||
): boolean {
|
||||
return role !== null && roleStrength(role) >= roleStrength(required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized role strings are namespaced (§4.5): audit events and API
|
||||
* responses carry `hierarchy:owner`, never a bare `owner`.
|
||||
*/
|
||||
export function namespacedHierarchyRole(role: HierarchyGrantRole): string {
|
||||
return `hierarchy:${role}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a node's ancestor chain (self included). Returns null when the
|
||||
* node does not exist — callers treat that exactly like no-grant (§3.1,
|
||||
* oracle-safe).
|
||||
*/
|
||||
export async function resolveAncestorChain(
|
||||
tx: Tx,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
): Promise<AncestorChain | null> {
|
||||
if (kind === 'company') {
|
||||
const rows = await tx
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { companyId: row.id } : null;
|
||||
}
|
||||
if (kind === 'estate') {
|
||||
const rows = await tx
|
||||
.select({ id: estates.id, companyId: estates.companyId })
|
||||
.from(estates)
|
||||
.where(eq(estates.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { companyId: row.companyId, estateId: row.id } : null;
|
||||
}
|
||||
if (kind === 'platform_project') {
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: platformProjects.id,
|
||||
estateId: platformProjects.estateId,
|
||||
companyId: estates.companyId,
|
||||
})
|
||||
.from(platformProjects)
|
||||
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
|
||||
.where(eq(platformProjects.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id }
|
||||
: null;
|
||||
}
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: workspaces.id,
|
||||
platformProjectId: workspaces.platformProjectId,
|
||||
estateId: platformProjects.estateId,
|
||||
companyId: estates.companyId,
|
||||
})
|
||||
.from(workspaces)
|
||||
.innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId))
|
||||
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
|
||||
.where(eq(workspaces.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? {
|
||||
companyId: row.companyId,
|
||||
estateId: row.estateId,
|
||||
platformProjectId: row.platformProjectId,
|
||||
workspaceId: row.id,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function maxRole(roles: readonly string[]): HierarchyGrantRole | null {
|
||||
let best: HierarchyGrantRole | null = null;
|
||||
for (const candidate of roles) {
|
||||
// Fail-closed: a value outside the vocabulary confers nothing.
|
||||
if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue;
|
||||
const role = candidate as HierarchyGrantRole;
|
||||
if (best === null || roleStrength(role) > roleStrength(best)) best = role;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective role of a user on a node: maximum over the user's grants whose
|
||||
* target is the node or any ancestor (§3.2); null = deny (§3.1). Missing
|
||||
* node → null.
|
||||
*/
|
||||
export async function evaluateEffectiveRole(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
): Promise<HierarchyGrantRole | null> {
|
||||
const chain = await resolveAncestorChain(tx, kind, id);
|
||||
if (!chain) return null;
|
||||
|
||||
const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)];
|
||||
if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId));
|
||||
if (chain.platformProjectId) {
|
||||
targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId));
|
||||
}
|
||||
|
||||
const rows = await tx
|
||||
.select({ role: hierarchyGrants.role })
|
||||
.from(hierarchyGrants)
|
||||
.where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions)));
|
||||
return maxRole(rows.map((r) => r.role));
|
||||
}
|
||||
|
||||
/**
|
||||
* All companies on which the user holds any effective role, i.e. companies
|
||||
* with a grant on the company itself or on any descendant (contract 1 §2.8:
|
||||
* a grant anywhere in the subtree discloses the company's chain upward).
|
||||
*/
|
||||
export async function grantedCompanyIds(tx: Tx, userId: string): Promise<string[]> {
|
||||
const grants = await tx
|
||||
.select({
|
||||
companyId: hierarchyGrants.companyId,
|
||||
estateId: hierarchyGrants.estateId,
|
||||
platformProjectId: hierarchyGrants.platformProjectId,
|
||||
})
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.userId, userId));
|
||||
|
||||
const companyIds = new Set<string>();
|
||||
const estateIds = new Set<string>();
|
||||
const platformProjectIds = new Set<string>();
|
||||
for (const grant of grants) {
|
||||
if (grant.companyId) companyIds.add(grant.companyId);
|
||||
else if (grant.estateId) estateIds.add(grant.estateId);
|
||||
else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId);
|
||||
}
|
||||
|
||||
if (platformProjectIds.size > 0) {
|
||||
const rows = await tx
|
||||
.select({ estateId: platformProjects.estateId })
|
||||
.from(platformProjects)
|
||||
.where(inArray(platformProjects.id, [...platformProjectIds]));
|
||||
for (const row of rows) estateIds.add(row.estateId);
|
||||
}
|
||||
if (estateIds.size > 0) {
|
||||
const rows = await tx
|
||||
.select({ companyId: estates.companyId })
|
||||
.from(estates)
|
||||
.where(inArray(estates.id, [...estateIds]));
|
||||
for (const row of rows) companyIds.add(row.companyId);
|
||||
}
|
||||
return [...companyIds];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyGrantEvaluationService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */
|
||||
effectiveRole(
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
tx?: Tx,
|
||||
): Promise<HierarchyGrantRole | null> {
|
||||
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
|
||||
}
|
||||
|
||||
async hasRole(
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
required: HierarchyGrantRole,
|
||||
tx?: Tx,
|
||||
): Promise<boolean> {
|
||||
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import {
|
||||
ChangeCompanyVisibilityDto,
|
||||
ChangeGrantDto,
|
||||
CreateCompanyDto,
|
||||
CreateEstateDto,
|
||||
CreateGrantDto,
|
||||
CreatePlatformProjectDto,
|
||||
DeleteNodeDto,
|
||||
RenameNodeDto,
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy.dto.js';
|
||||
import { HierarchyRepository } from './hierarchy.repository.js';
|
||||
import { HierarchyService } from './hierarchy.service.js';
|
||||
|
||||
/**
|
||||
* The hierarchy command family (contract 1 §5, §6.3). This controller is the
|
||||
* closed HTTP surface over the hierarchy class tables: the route-inventory
|
||||
* witness asserts these routes and no others exist. Delete commands take an
|
||||
* optional body (idempotency key) via POST-style DTOs; every mutation is
|
||||
* audited on its own transaction by the repository.
|
||||
*/
|
||||
@Controller('api/hierarchy')
|
||||
@UseGuards(AuthGuard)
|
||||
export class HierarchyController {
|
||||
constructor(
|
||||
private readonly repository: HierarchyRepository,
|
||||
private readonly service: HierarchyService,
|
||||
) {}
|
||||
|
||||
// ── companies ────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('companies')
|
||||
async createCompany(@CurrentUser() user: { id: string }, @Body() dto: CreateCompanyDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createCompany({
|
||||
actorId: user.id,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Companies the caller holds a grant on (directly or via a descendant). */
|
||||
@Get('companies')
|
||||
listGrantedCompanies(@CurrentUser() user: { id: string }) {
|
||||
return this.repository.listGrantedCompanies(user.id);
|
||||
}
|
||||
|
||||
/** Directory-class companies, closed-field (§2.8). */
|
||||
@Get('companies/directory')
|
||||
listDirectory() {
|
||||
return this.repository.listDirectory();
|
||||
}
|
||||
|
||||
@Post('companies/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renameCompany(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renameCompany({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('companies/:id/visibility')
|
||||
@HttpCode(200)
|
||||
async changeCompanyVisibility(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ChangeCompanyVisibilityDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.changeCompanyVisibility({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
visibility: dto.visibility,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('companies/:id')
|
||||
async deleteCompany(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deleteCompany({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── estates ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('estates')
|
||||
async createEstate(@CurrentUser() user: { id: string }, @Body() dto: CreateEstateDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createEstate({
|
||||
actorId: user.id,
|
||||
companyId: dto.companyId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('estates/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renameEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renameEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('estates/:id/transfer')
|
||||
@HttpCode(200)
|
||||
async transferEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: TransferEstateDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.transferEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
destinationCompanyId: dto.destinationCompanyId,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('estates/:id')
|
||||
async deleteEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deleteEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── platform projects ────────────────────────────────────────────────────
|
||||
|
||||
@Post('platform-projects')
|
||||
async createPlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body() dto: CreatePlatformProjectDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createPlatformProject({
|
||||
actorId: user.id,
|
||||
estateId: dto.estateId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('platform-projects/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renamePlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renamePlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('platform-projects/:id/transfer')
|
||||
@HttpCode(200)
|
||||
async transferPlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: TransferPlatformProjectDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.transferPlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
destinationEstateId: dto.destinationEstateId,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('platform-projects/:id')
|
||||
async deletePlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deletePlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── grants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('grants')
|
||||
async createGrant(@CurrentUser() user: { id: string }, @Body() dto: CreateGrantDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createGrant({
|
||||
actorId: user.id,
|
||||
userId: dto.userId,
|
||||
targetKind: dto.targetKind,
|
||||
targetId: dto.targetId,
|
||||
role: dto.role,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('grants/:id/change')
|
||||
@HttpCode(200)
|
||||
async changeGrant(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ChangeGrantDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.changeGrant({
|
||||
actorId: user.id,
|
||||
grantId: id,
|
||||
role: dto.role,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('grants/:id')
|
||||
async revokeGrant(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.revokeGrant({
|
||||
actorId: user.id,
|
||||
grantId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { COMPANY_VISIBILITY, HIERARCHY_GRANT_ROLES } from '@mosaicstack/db';
|
||||
import { IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Hierarchy command DTOs (contract 1 §5, contract 2 §4/§7).
|
||||
*
|
||||
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so a
|
||||
* payload field absent from these classes is a 400. That closure is itself
|
||||
* contract surface:
|
||||
* - CreateCompanyDto declares NO visibility field — creation is always
|
||||
* private (contract 1 §5.5); a visibility argument is refused by the pipe.
|
||||
* - CreateGrantDto declares NO teamId field — team grant subjects are
|
||||
* suspended (contract 2 §1.4/§7.5); a team subject is refused by the pipe.
|
||||
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
|
||||
* assertion proves the pipe sees the decorators.
|
||||
*/
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||||
const SLUG_MESSAGE = 'slug must be lowercase alphanumeric with interior hyphens';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
||||
slug!: string;
|
||||
|
||||
/** Client-supplied idempotency key (REQ-AUD-001 replay); server-generated when absent. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class RenameNodeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class ChangeCompanyVisibilityDto {
|
||||
@IsIn(COMPANY_VISIBILITY)
|
||||
visibility!: (typeof COMPANY_VISIBILITY)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class DeleteNodeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class CreateEstateDto {
|
||||
@IsUUID()
|
||||
companyId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
||||
slug!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class CreatePlatformProjectDto {
|
||||
@IsUUID()
|
||||
estateId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
||||
slug!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class TransferEstateDto {
|
||||
@IsUUID()
|
||||
destinationCompanyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class TransferPlatformProjectDto {
|
||||
@IsUUID()
|
||||
destinationEstateId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class CreateGrantDto {
|
||||
/** Subject user (better-auth text id). No teamId field — see module doc. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
userId!: string;
|
||||
|
||||
@IsIn(['company', 'estate', 'platform_project'])
|
||||
targetKind!: 'company' | 'estate' | 'platform_project';
|
||||
|
||||
@IsUUID()
|
||||
targetId!: string;
|
||||
|
||||
/** Bare vocabulary on requests; responses and audit events are namespaced (§4.5). */
|
||||
@IsIn(HIERARCHY_GRANT_ROLES)
|
||||
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class ChangeGrantDto {
|
||||
@IsIn(HIERARCHY_GRANT_ROLES)
|
||||
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
|
||||
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
|
||||
import { HierarchyController } from './hierarchy.controller.js';
|
||||
import { HierarchyRepository } from './hierarchy.repository.js';
|
||||
import { HierarchyService } from './hierarchy.service.js';
|
||||
|
||||
/**
|
||||
* Hierarchy (tenancy/authorization structure) feature module.
|
||||
*
|
||||
* M4-1b-i shipped the audit event + outbox machinery (contract 1 §5.2);
|
||||
* M4-1b-ii adds the command family — the closed route surface asserted by
|
||||
* the route-inventory witness — plus grant evaluation (contract 2 §3).
|
||||
* HierarchyRepository is the sole class-table writer (writer-coverage
|
||||
* allowlist); every mutation runs authorize → mutate → audit in one
|
||||
* transaction.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HierarchyController],
|
||||
providers: [
|
||||
HierarchyAuditRepository,
|
||||
HierarchyGrantEvaluationService,
|
||||
HierarchyRepository,
|
||||
HierarchyService,
|
||||
],
|
||||
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
|
||||
})
|
||||
export class HierarchyModule {}
|
||||
@@ -1,935 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
inArray,
|
||||
platformProjects,
|
||||
users,
|
||||
workspaces,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import {
|
||||
appendHierarchyEvent,
|
||||
buildNodeSnapshot,
|
||||
HierarchyAuditIdempotencyConflictError,
|
||||
} from './hierarchy-audit.repository.js';
|
||||
import {
|
||||
evaluateEffectiveRole,
|
||||
grantedCompanyIds,
|
||||
namespacedHierarchyRole,
|
||||
roleAtLeast,
|
||||
type GrantTargetKind,
|
||||
type HierarchyGrantRole,
|
||||
} from './hierarchy-grant-evaluation.js';
|
||||
|
||||
/**
|
||||
* Hierarchy command repository (contract 1 §5, contract 2 §4).
|
||||
*
|
||||
* The ONLY writer of the hierarchy class tables (companies, estates,
|
||||
* platform_projects, hierarchy_grants) — it is the writer-coverage
|
||||
* allowlist's sole entry. Every command runs one transaction that
|
||||
* authorizes (live grant evaluation inside the same transaction), mutates,
|
||||
* and appends the semantic audit event + outbox record via the M4-1b-i
|
||||
* machinery, so state, event, and outbox commit or roll back together
|
||||
* (REQ-AUD-001).
|
||||
*
|
||||
* Authorization failure and target-not-found both return `not_found`
|
||||
* (contract 1 §6.7: no existence oracle — an unauthorized caller learns
|
||||
* nothing a stranger would not). `forbidden` appears only where the caller
|
||||
* already knows the surface exists independent of any node: the admin-only
|
||||
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
|
||||
*/
|
||||
|
||||
export type HierarchyCommandFailure =
|
||||
| { readonly ok: false; readonly error: 'not_found' }
|
||||
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
|
||||
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
|
||||
|
||||
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
|
||||
|
||||
export interface CompanyView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly visibility: string;
|
||||
}
|
||||
|
||||
export interface NodeView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
export interface GrantView {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly targetKind: GrantTargetKind;
|
||||
readonly targetId: string;
|
||||
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
|
||||
readonly role: string;
|
||||
readonly grantedBy: string;
|
||||
}
|
||||
|
||||
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
|
||||
export interface DirectoryEntry {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
|
||||
type GrantRow = typeof hierarchyGrants.$inferSelect;
|
||||
|
||||
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
|
||||
|
||||
function conflict(message: string): HierarchyCommandFailure {
|
||||
return { ok: false, error: 'conflict', message };
|
||||
}
|
||||
|
||||
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
|
||||
if (row.companyId) return { kind: 'company', id: row.companyId };
|
||||
if (row.estateId) return { kind: 'estate', id: row.estateId };
|
||||
return { kind: 'platform_project', id: row.platformProjectId as string };
|
||||
}
|
||||
|
||||
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
|
||||
function grantSnapshot(row: GrantRow): Record<string, unknown> {
|
||||
const target = grantTarget(row);
|
||||
return {
|
||||
id: row.id,
|
||||
subject: { userId: row.userId },
|
||||
target: { kind: target.kind, id: target.id },
|
||||
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
||||
grantedBy: row.grantedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function grantView(row: GrantRow): GrantView {
|
||||
const target = grantTarget(row);
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId as string,
|
||||
targetKind: target.kind,
|
||||
targetId: target.id,
|
||||
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
||||
grantedBy: row.grantedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function companyView(row: typeof companies.$inferSelect): CompanyView {
|
||||
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
|
||||
}
|
||||
|
||||
interface CommandContext {
|
||||
readonly actorId: string;
|
||||
readonly idempotencyKey: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
private async run<T>(
|
||||
idempotencyKey: string | undefined,
|
||||
actorId: string,
|
||||
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
|
||||
): Promise<HierarchyResult<T>> {
|
||||
const ctx: CommandContext = {
|
||||
actorId,
|
||||
idempotencyKey: idempotencyKey ?? randomUUID(),
|
||||
correlationId: randomUUID(),
|
||||
};
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => body(tx, ctx));
|
||||
} catch (error) {
|
||||
// A key replayed with different content aborts the whole command —
|
||||
// the transaction (state change included) has rolled back (§6.4).
|
||||
if (error instanceof HierarchyAuditIdempotencyConflictError) {
|
||||
return conflict(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async requireOwner(
|
||||
tx: Tx,
|
||||
actorId: string,
|
||||
kind: GrantTargetKind,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
|
||||
}
|
||||
|
||||
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
|
||||
const rows = await tx
|
||||
.select({ role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, actorId))
|
||||
.limit(1);
|
||||
return rows[0]?.role === 'admin';
|
||||
}
|
||||
|
||||
// ── companies ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Any authenticated user may create a company; the same audited operation
|
||||
* writes the creator's initial owner grant (§4.3), causation-linked to the
|
||||
* create event. Visibility is always 'private' — the command takes no
|
||||
* visibility input (§5.5).
|
||||
*/
|
||||
createCompany(input: {
|
||||
actorId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const inserted = await tx
|
||||
.insert(companies)
|
||||
.values({ name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const company = inserted[0];
|
||||
if (!company) return conflict('company slug already exists');
|
||||
|
||||
const grantRows = await tx
|
||||
.insert(hierarchyGrants)
|
||||
.values({
|
||||
userId: ctx.actorId,
|
||||
companyId: company.id,
|
||||
role: 'owner',
|
||||
grantedBy: ctx.actorId,
|
||||
})
|
||||
.returning();
|
||||
const grant = grantRows[0] as GrantRow;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
const created = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_create',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: created.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:grant`,
|
||||
});
|
||||
return { ok: true, company: companyView(company), grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
renameCompany(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ name: input.name, updatedAt: new Date() })
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.returning();
|
||||
const company = updated[0] as typeof companies.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, company: companyView(company) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only until the company-CRUD capability ratifies (§5.5) — the one
|
||||
* hierarchy mutation a platform admin performs without a grant. A
|
||||
* non-admin caller (owner included) gets `forbidden` before any company
|
||||
* read: the refusal reveals nothing about the target's existence.
|
||||
*/
|
||||
changeCompanyVisibility(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
visibility: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'forbidden',
|
||||
message: 'visibility change is platform-admin-only',
|
||||
};
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ visibility: input.visibility, updatedAt: new Date() })
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.returning();
|
||||
const company = updated[0] as typeof companies.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'visibility_change',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
// Old and new values are event content (§5.5).
|
||||
targetSnapshot: {
|
||||
...snapshot,
|
||||
previousVisibility: previous.visibility,
|
||||
visibility: company.visibility,
|
||||
},
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, company: companyView(company) };
|
||||
});
|
||||
}
|
||||
|
||||
deleteCompany(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: estates.id })
|
||||
.from(estates)
|
||||
.where(eq(estates.companyId, input.companyId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('company still has estates');
|
||||
|
||||
// Snapshot and grants are read before the delete; target FKs cascade
|
||||
// the grant rows, and each cascaded deletion is audited (§5.2).
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.companyId, input.companyId));
|
||||
|
||||
await tx.delete(companies).where(eq(companies.id, input.companyId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'company',
|
||||
targetId: input.companyId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.companyId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── estates ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Child creation requires owner on the parent and confers no grant (§4.3). */
|
||||
createEstate(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(estates)
|
||||
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const estate = inserted[0];
|
||||
if (!estate) return conflict('estate slug already exists in company');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'estate',
|
||||
targetId: estate.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
renameEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(estates)
|
||||
.set({ name: input.name })
|
||||
.where(eq(estates.id, input.estateId))
|
||||
.returning();
|
||||
const estate = updated[0] as typeof estates.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'estate',
|
||||
targetId: estate.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
|
||||
transferEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
destinationCompanyId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
||||
const estate = rows[0];
|
||||
if (!estate) return NOT_FOUND;
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (estate.companyId === input.destinationCompanyId) {
|
||||
return conflict('estate already belongs to the destination company');
|
||||
}
|
||||
const collision = await tx
|
||||
.select({ id: estates.id })
|
||||
.from(estates)
|
||||
.where(
|
||||
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
|
||||
)
|
||||
.limit(1);
|
||||
if (collision.length > 0) return conflict('destination company already has that estate slug');
|
||||
|
||||
const parents = await tx
|
||||
.select({ id: companies.id, slug: companies.slug })
|
||||
.from(companies)
|
||||
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
|
||||
const source = parents.find((p) => p.id === estate.companyId);
|
||||
const destination = parents.find((p) => p.id === input.destinationCompanyId);
|
||||
if (!source || !destination) return NOT_FOUND;
|
||||
|
||||
await tx
|
||||
.update(estates)
|
||||
.set({ companyId: input.destinationCompanyId })
|
||||
.where(eq(estates.id, input.estateId));
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
targetId: input.estateId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
|
||||
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
deleteEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: platformProjects.id })
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.estateId, input.estateId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('estate still has platform projects');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.estateId, input.estateId));
|
||||
|
||||
await tx.delete(estates).where(eq(estates.id, input.estateId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'estate',
|
||||
targetId: input.estateId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.estateId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── platform projects ────────────────────────────────────────────────────
|
||||
|
||||
createPlatformProject(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(platformProjects)
|
||||
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const project = inserted[0];
|
||||
if (!project) return conflict('platform project slug already exists in estate');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'platform_project',
|
||||
targetId: project.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
renamePlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (
|
||||
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
||||
) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(platformProjects)
|
||||
.set({ name: input.name })
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.returning();
|
||||
const project = updated[0] as typeof platformProjects.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'platform_project',
|
||||
targetId: project.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
transferPlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
destinationEstateId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.limit(1);
|
||||
const project = rows[0];
|
||||
if (!project) return NOT_FOUND;
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (project.estateId === input.destinationEstateId) {
|
||||
return conflict('platform project already belongs to the destination estate');
|
||||
}
|
||||
const collision = await tx
|
||||
.select({ id: platformProjects.id })
|
||||
.from(platformProjects)
|
||||
.where(
|
||||
and(
|
||||
eq(platformProjects.estateId, input.destinationEstateId),
|
||||
eq(platformProjects.slug, project.slug),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (collision.length > 0) {
|
||||
return conflict('destination estate already has that platform project slug');
|
||||
}
|
||||
|
||||
const parents = await tx
|
||||
.select({ id: estates.id, slug: estates.slug })
|
||||
.from(estates)
|
||||
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
|
||||
const source = parents.find((p) => p.id === project.estateId);
|
||||
const destination = parents.find((p) => p.id === input.destinationEstateId);
|
||||
if (!source || !destination) return NOT_FOUND;
|
||||
|
||||
await tx
|
||||
.update(platformProjects)
|
||||
.set({ estateId: input.destinationEstateId })
|
||||
.where(eq(platformProjects.id, input.platformProjectId));
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'transfer',
|
||||
targetKind: 'platform_project',
|
||||
targetId: input.platformProjectId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
|
||||
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
deletePlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (
|
||||
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
||||
) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: workspaces.id })
|
||||
.from(workspaces)
|
||||
.where(eq(workspaces.platformProjectId, input.platformProjectId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('platform project still has workspaces');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
|
||||
|
||||
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'platform_project',
|
||||
targetId: input.platformProjectId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.platformProjectId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── grants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Grant management requires effective owner on the target (§4.1). */
|
||||
createGrant(input: {
|
||||
actorId: string;
|
||||
userId: string;
|
||||
targetKind: GrantTargetKind;
|
||||
targetId: string;
|
||||
role: HierarchyGrantRole;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const subject = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, input.userId))
|
||||
.limit(1);
|
||||
if (subject.length === 0) return conflict('subject user does not exist');
|
||||
|
||||
const inserted = await tx
|
||||
.insert(hierarchyGrants)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
companyId: input.targetKind === 'company' ? input.targetId : null,
|
||||
estateId: input.targetKind === 'estate' ? input.targetId : null,
|
||||
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
|
||||
role: input.role,
|
||||
grantedBy: ctx.actorId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const grant = inserted[0];
|
||||
if (!grant) return conflict('grant already exists');
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_create',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
changeGrant(input: {
|
||||
actorId: string;
|
||||
grantId: string;
|
||||
role: HierarchyGrantRole;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.limit(1);
|
||||
const existing = rows[0];
|
||||
if (!existing) return NOT_FOUND;
|
||||
const target = grantTarget(existing);
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
// Team subjects are suspended (§1.4); the command surface never
|
||||
// creates them, so this only fires on out-of-band rows.
|
||||
if (!existing.userId) return conflict('team grant subjects are suspended');
|
||||
if (existing.role === input.role) return conflict('grant already holds that role');
|
||||
|
||||
const duplicate = await tx
|
||||
.select({ id: hierarchyGrants.id })
|
||||
.from(hierarchyGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(hierarchyGrants.userId, existing.userId),
|
||||
target.kind === 'company'
|
||||
? eq(hierarchyGrants.companyId, target.id)
|
||||
: target.kind === 'estate'
|
||||
? eq(hierarchyGrants.estateId, target.id)
|
||||
: eq(hierarchyGrants.platformProjectId, target.id),
|
||||
eq(hierarchyGrants.role, input.role),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (duplicate.length > 0) {
|
||||
return conflict('subject already holds that role on the target');
|
||||
}
|
||||
|
||||
const updated = await tx
|
||||
.update(hierarchyGrants)
|
||||
.set({ role: input.role })
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.returning();
|
||||
const grant = updated[0] as GrantRow;
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_change',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: {
|
||||
...grantSnapshot(grant),
|
||||
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
|
||||
},
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
|
||||
revokeGrant(input: {
|
||||
actorId: string;
|
||||
grantId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ revokedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.limit(1);
|
||||
const existing = rows[0];
|
||||
if (!existing) return NOT_FOUND;
|
||||
const target = grantTarget(existing);
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
|
||||
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: existing.id,
|
||||
targetSnapshot: grantSnapshot(existing),
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, revokedId: existing.id };
|
||||
});
|
||||
}
|
||||
|
||||
// ── reads ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The directory: every directory-class company, closed-field (§2.8). The
|
||||
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
|
||||
*/
|
||||
async listDirectory(): Promise<DirectoryEntry[]> {
|
||||
return this.db
|
||||
.select({ id: companies.id, name: companies.name, slug: companies.slug })
|
||||
.from(companies)
|
||||
.where(eq(companies.visibility, 'directory'))
|
||||
.orderBy(asc(companies.name));
|
||||
}
|
||||
|
||||
/** Companies the user holds any grant on (company or descendant, §2.8). */
|
||||
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
|
||||
const ids = await grantedCompanyIds(this.db, userId);
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(inArray(companies.id, ids))
|
||||
.orderBy(asc(companies.name));
|
||||
return rows.map(companyView);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { HierarchyCommandFailure, HierarchyResult } from './hierarchy.repository.js';
|
||||
|
||||
/**
|
||||
* Maps repository result unions onto HTTP exceptions. `not_found` carries
|
||||
* one fixed message for every cause — missing node and unauthorized caller
|
||||
* are indistinguishable on the wire (contract 1 §6.7).
|
||||
*/
|
||||
@Injectable()
|
||||
export class HierarchyService {
|
||||
unwrap<T>(result: HierarchyResult<T>): T {
|
||||
if (result.ok) return result;
|
||||
throw this.toException(result);
|
||||
}
|
||||
|
||||
private toException(failure: HierarchyCommandFailure): Error {
|
||||
switch (failure.error) {
|
||||
case 'not_found':
|
||||
return new NotFoundException('hierarchy node not found');
|
||||
case 'forbidden':
|
||||
return new ForbiddenException(failure.message);
|
||||
case 'conflict':
|
||||
return new ConflictException(failure.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { AppModule } from './app.module.js';
|
||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { mountSpaStatic } from './spa/serve-spa.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||
@@ -69,7 +68,6 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
mountAuthHandler(app);
|
||||
mountMcpHandler(app, app.get(McpService));
|
||||
await mountSpaStatic(app);
|
||||
|
||||
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
|
||||
@@ -176,18 +176,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
|
||||
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
|
||||
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
|
||||
const scopes = deriveMcpToolScopesForUser({ role });
|
||||
expect([...scopes].sort()).toEqual([...memberScopes].sort());
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
|
||||
}
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
|
||||
@@ -322,20 +311,14 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
|
||||
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
|
||||
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
|
||||
// ownership sees exactly what an unprivileged member with the same
|
||||
// ownership would see — here, only the one project they own, and nothing
|
||||
// tenant-wide or platform-wide.
|
||||
const fixtures = {
|
||||
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
|
||||
const { service } = makeService({
|
||||
projects: [
|
||||
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
|
||||
{
|
||||
id: 'project-tenant-a',
|
||||
ownerId: 'other-user-a',
|
||||
teamId: 'tenant-a',
|
||||
name: 'same tenant, unowned',
|
||||
name: 'same tenant',
|
||||
},
|
||||
{
|
||||
id: 'project-tenant-b',
|
||||
@@ -345,50 +328,39 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
},
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-owned', projectId: 'project-owned' },
|
||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
|
||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
||||
],
|
||||
};
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
|
||||
const actors = [
|
||||
makeAdminActor('role-bearing-user', 'tenant-a'),
|
||||
makePlatformAdminActor('role-bearing-user'),
|
||||
];
|
||||
service.registerTools(server, actor);
|
||||
|
||||
for (const actor of actors) {
|
||||
const { service } = makeService(fixtures);
|
||||
const { server, tools } = makeCapturingServer();
|
||||
service.registerTools(server, actor);
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
|
||||
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
|
||||
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
|
||||
}
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
|
||||
});
|
||||
|
||||
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
|
||||
const { service, brain } = makeService({
|
||||
projects: [
|
||||
// §1.1 retirement: content visibility comes from ownership, not the
|
||||
// tenant-admin role — the acting user owns the tenant-a project.
|
||||
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
|
||||
],
|
||||
missions: [
|
||||
@@ -401,29 +373,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
// Platform role no longer derives task-write scopes (§1.1 retirement):
|
||||
// a role-derived admin actor is scope-denied before any tenant logic.
|
||||
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
service.registerTools(server, roleDerivedAdmin);
|
||||
await expect(
|
||||
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
|
||||
).rejects.toThrow('MCP tool scope denied');
|
||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
||||
|
||||
// The tenant-scoping checks below sit behind the scope gate; exercise
|
||||
// them with explicitly granted task-write scopes (how grant-mapped
|
||||
// scopes will arrive), not with a platform role.
|
||||
tools.clear();
|
||||
const actor = createMcpActorContext({
|
||||
userId: 'tenant-admin-user',
|
||||
tenantId: 'tenant-a',
|
||||
role: 'member',
|
||||
scopes: [
|
||||
...deriveMcpToolScopesForUser({ role: 'member' }),
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
],
|
||||
});
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
@@ -466,7 +416,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
|
||||
it('keeps admin-only coordination tools on server-derived paths', async () => {
|
||||
const { service, coord } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const member = makeMemberActor('authenticated-user');
|
||||
@@ -483,25 +433,10 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
||||
|
||||
// §1.1 retirement: platform-admin no longer derives coord scopes either.
|
||||
tools.clear();
|
||||
service.registerTools(server, platformAdmin);
|
||||
const platformAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await expect(platformAdminTool.handler({})).rejects.toThrow(
|
||||
'MCP tool scope denied: coord:read',
|
||||
);
|
||||
|
||||
// An explicitly granted coord:read scope reaches the server-derived
|
||||
// path (caller-supplied projectPath is stripped by the schema).
|
||||
tools.clear();
|
||||
const grantedActor = createMcpActorContext({
|
||||
userId: 'granted-user',
|
||||
role: 'member',
|
||||
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
|
||||
});
|
||||
service.registerTools(server, grantedActor);
|
||||
const grantedTool = getTool(tools, 'coord_list_tasks');
|
||||
await grantedTool.handler({ projectPath: '/tmp/victim' });
|
||||
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
|
||||
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,20 @@ interface SessionEntry {
|
||||
actor: McpActorContext;
|
||||
}
|
||||
|
||||
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
|
||||
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
MCP_TOOL_SCOPES.brain_list_missions,
|
||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
||||
MCP_TOOL_SCOPES.memory_search,
|
||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
||||
MCP_TOOL_SCOPES.memory_save_preference,
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
@@ -75,17 +89,15 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: platform role confers NO MCP scope elevation — the
|
||||
* former tenant-admin/global-admin scope sets keyed on users.role are
|
||||
* retired. Every authenticated user receives the base member set; task
|
||||
* writes and coordination scopes attach to explicit hierarchy grants when
|
||||
* the MCP grant mapping lands, never to a platform role. The role
|
||||
* parameter is kept for caller compatibility and deliberately ignored.
|
||||
*/
|
||||
export function deriveMcpToolScopesForUser(_input: {
|
||||
export function deriveMcpToolScopesForUser(input: {
|
||||
role?: string | null;
|
||||
}): ReadonlySet<McpToolScope> {
|
||||
if (input.role === 'platform-admin' || input.role === 'super-admin') {
|
||||
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
if (input.role === 'admin') {
|
||||
return new Set(TENANT_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
return new Set(MEMBER_MCP_SCOPES);
|
||||
}
|
||||
|
||||
@@ -156,22 +168,41 @@ type TaskLike = TenantScopedLike & {
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: `users.role` confers NO content visibility — the former
|
||||
* global-admin/tenant-admin filter short-circuits keyed on the platform role
|
||||
* are retired along with the role-derived scope sets. Content reaches an MCP
|
||||
* actor through ownership only; widened access arrives as explicit hierarchy
|
||||
* grants when the MCP grant mapping lands.
|
||||
*/
|
||||
function isGlobalAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'platform-admin' || actor.role === 'super-admin';
|
||||
}
|
||||
|
||||
function isTenantAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'admin';
|
||||
}
|
||||
|
||||
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
|
||||
return (
|
||||
record.tenantId === actor.tenantId ||
|
||||
record.organizationId === actor.tenantId ||
|
||||
record.teamId === actor.tenantId
|
||||
);
|
||||
}
|
||||
|
||||
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
|
||||
return projects.filter((project) => project.ownerId === actor.userId);
|
||||
if (isGlobalAdminActor(actor)) return projects;
|
||||
return projects.filter(
|
||||
(project) =>
|
||||
project.ownerId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
|
||||
);
|
||||
}
|
||||
|
||||
function filterMissionsByDirectActorScope<T extends MissionLike>(
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): T[] {
|
||||
return missions.filter((mission) => mission.userId === actor.userId);
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
return missions.filter(
|
||||
(mission) =>
|
||||
mission.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
|
||||
);
|
||||
}
|
||||
|
||||
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
|
||||
@@ -262,6 +293,7 @@ export class McpService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
|
||||
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
|
||||
}
|
||||
@@ -270,6 +302,8 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
|
||||
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
|
||||
const projectIds = new Set(
|
||||
filterProjectsForActor(actor, projects).map((project) => project.id),
|
||||
@@ -283,6 +317,7 @@ export class McpService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
|
||||
if (!mission) return false;
|
||||
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
|
||||
@@ -304,7 +339,7 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
refs: { projectId?: string | null; missionId?: string | null },
|
||||
): Promise<void> {
|
||||
if (!refs.projectId && !refs.missionId) {
|
||||
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
|
||||
throw new Error('MCP task scope denied');
|
||||
}
|
||||
await this.assertTaskReferencesAuthorized(actor, refs);
|
||||
@@ -314,6 +349,8 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
tasks: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return tasks;
|
||||
|
||||
const [projects, missions] = await Promise.all([
|
||||
this.brain.projects.findAll(),
|
||||
this.brain.missions.findAll(),
|
||||
@@ -330,6 +367,7 @@ export class McpService implements OnModuleDestroy {
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
|
||||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
|
||||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
|
||||
);
|
||||
|
||||
@@ -108,13 +108,11 @@ export class MissionsController {
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
// dto.status is deliberately not forwarded: mission_tasks.status is
|
||||
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
|
||||
// the repo strips it as well.
|
||||
return this.brain.missionTasks.create({
|
||||
missionId,
|
||||
taskId: dto.taskId,
|
||||
userId: user.id,
|
||||
status: dto.status,
|
||||
description: dto.description,
|
||||
notes: dto.notes,
|
||||
pr: dto.pr,
|
||||
|
||||
@@ -77,12 +77,6 @@ export class CreateMissionTaskDto {
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
/**
|
||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
||||
* The field stays declared because the global ValidationPipe runs with
|
||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
@@ -108,12 +102,6 @@ export class UpdateMissionTaskDto {
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
/**
|
||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
||||
* The field stays declared because the global ValidationPipe runs with
|
||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* E2E integration test — SPA static serving (Phase P5 cutover, #1444; tests
|
||||
* added in P6, #1445, review follow-up SF1 on PR #1453).
|
||||
*
|
||||
* Boots a real Nest+Fastify app the way main.ts does (mountSpaStatic after the
|
||||
* controllers) against a fixture dist directory, and pins the serving
|
||||
* contract:
|
||||
*
|
||||
* 1. `/` and client-side deep links fall back to index.html.
|
||||
* 2. Declared API routes win over the catch-all.
|
||||
* 3. Unknown backend paths (/api, /mcp, /socket.io) are JSON 404s, never the
|
||||
* SPA page — including with a query string (`/api?x=1`).
|
||||
* 4. Static files are served exactly; hashed /assets/ files get immutable
|
||||
* cache headers, everything else revalidates (max-age=0), and a missing
|
||||
* /assets/ file is a 404 — never the SPA fallback.
|
||||
* 5. WEB_DIST_DIR unset disables SPA serving entirely.
|
||||
* 6. WEB_DIST_DIR pointing at a directory without index.html fails at boot.
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Controller, Get, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { mountSpaStatic } from './serve-spa.js';
|
||||
|
||||
const INDEX_HTML = '<!doctype html><html><body>mosaic spa fixture</body></html>\n';
|
||||
const ASSET_JS = 'console.log("hashed asset");\n';
|
||||
|
||||
@Controller('api/spa-test')
|
||||
class SpaTestController {
|
||||
@Get('ping')
|
||||
ping(): { ok: boolean } {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function createApp(): Promise<INestApplication> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [SpaTestController],
|
||||
}).compile();
|
||||
|
||||
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
// Mirror main.ts ordering: SPA mounting happens after the app (and its
|
||||
// controllers) exist, before listen.
|
||||
await mountSpaStatic(app as NestFastifyApplication);
|
||||
await (app as NestFastifyApplication).getHttpAdapter().getInstance().ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('SPA static serving — fixture dist dir', () => {
|
||||
let app: INestApplication;
|
||||
let distDir: string;
|
||||
let previousWebDistDir: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
distDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-fixture-'));
|
||||
await writeFile(path.join(distDir, 'index.html'), INDEX_HTML);
|
||||
await writeFile(path.join(distDir, 'favicon.svg'), '<svg></svg>\n');
|
||||
await mkdir(path.join(distDir, 'assets'), { recursive: true });
|
||||
await writeFile(path.join(distDir, 'assets', 'app-abc123.js'), ASSET_JS);
|
||||
|
||||
previousWebDistDir = process.env['WEB_DIST_DIR'];
|
||||
process.env['WEB_DIST_DIR'] = distDir;
|
||||
app = await createApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousWebDistDir === undefined) {
|
||||
delete process.env['WEB_DIST_DIR'];
|
||||
} else {
|
||||
process.env['WEB_DIST_DIR'] = previousWebDistDir;
|
||||
}
|
||||
await app.close();
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('serves index.html at /', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe(INDEX_HTML);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
});
|
||||
|
||||
it('falls back to index.html for client-side deep links', async () => {
|
||||
for (const deepLink of ['/chat', '/projects/42', '/settings']) {
|
||||
const res = await request(app.getHttpServer()).get(deepLink);
|
||||
expect(res.status, deepLink).toBe(200);
|
||||
expect(res.text, deepLink).toBe(INDEX_HTML);
|
||||
}
|
||||
});
|
||||
|
||||
it('declared API routes win over the SPA catch-all', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/spa-test/ping');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('unknown backend paths are JSON 404s, never the SPA page', async () => {
|
||||
for (const backendPath of ['/api/nope', '/api', '/mcp/nope', '/socket.io/nope']) {
|
||||
const res = await request(app.getHttpServer()).get(backendPath);
|
||||
expect(res.status, backendPath).toBe(404);
|
||||
expect(res.headers['content-type'], backendPath).toContain('application/json');
|
||||
expect(res.body, backendPath).toMatchObject({ error: 'Not Found', statusCode: 404 });
|
||||
}
|
||||
});
|
||||
|
||||
it('a backend path with a query string is still a backend 404 (/api?x=1)', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api?x=1');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
});
|
||||
|
||||
it('serves static files exactly', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/favicon.svg');
|
||||
expect(res.status).toBe(200);
|
||||
// supertest buffers image/svg+xml as a Buffer body, not res.text.
|
||||
const body = res.text || (res.body as Buffer).toString('utf8');
|
||||
expect(body).toBe('<svg></svg>\n');
|
||||
});
|
||||
|
||||
it('hashed /assets/ files get immutable cache headers', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/assets/app-abc123.js');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe(ASSET_JS);
|
||||
expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
});
|
||||
|
||||
it('missing /assets/ files are 404s, never the SPA page with an immutable header', async () => {
|
||||
// The exact request a browser with a stale index.html makes after a
|
||||
// deploy: the old hashed filename. Serving index.html here would poison
|
||||
// caches with a year-long immutable entry whose body is HTML.
|
||||
for (const missingAsset of ['/assets/app-old999.js', '/assets/app-old999.js?v=1']) {
|
||||
const res = await request(app.getHttpServer()).get(missingAsset);
|
||||
expect(res.status, missingAsset).toBe(404);
|
||||
expect(res.text, missingAsset).not.toContain('mosaic spa fixture');
|
||||
// The 404 carries no cache-control at all; ?? '' keeps the assertion valid.
|
||||
expect(res.headers['cache-control'] ?? '', missingAsset).not.toContain('immutable');
|
||||
}
|
||||
});
|
||||
|
||||
it('index.html and non-asset files revalidate (no immutable caching)', async () => {
|
||||
for (const revalidating of ['/', '/chat', '/favicon.svg']) {
|
||||
const res = await request(app.getHttpServer()).get(revalidating);
|
||||
expect(res.headers['cache-control'], revalidating).not.toContain('immutable');
|
||||
}
|
||||
});
|
||||
|
||||
it('non-GET unmatched requests keep the stock 404 (catch-all is GET/HEAD only)', async () => {
|
||||
const res = await request(app.getHttpServer()).post('/chat');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.text).not.toContain('mosaic spa fixture');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SPA static serving — configuration edges', () => {
|
||||
it('WEB_DIST_DIR unset disables SPA serving', async () => {
|
||||
const previous = process.env['WEB_DIST_DIR'];
|
||||
delete process.env['WEB_DIST_DIR'];
|
||||
try {
|
||||
const app = await createApp();
|
||||
const res = await request(app.getHttpServer()).get('/chat');
|
||||
expect(res.status).toBe(404);
|
||||
await app.close();
|
||||
} finally {
|
||||
if (previous !== undefined) {
|
||||
process.env['WEB_DIST_DIR'] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('WEB_DIST_DIR without index.html fails at boot', async () => {
|
||||
const emptyDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-empty-'));
|
||||
const previous = process.env['WEB_DIST_DIR'];
|
||||
process.env['WEB_DIST_DIR'] = emptyDir;
|
||||
try {
|
||||
await expect(createApp()).rejects.toThrow(/index\.html.*does not exist/);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env['WEB_DIST_DIR'];
|
||||
} else {
|
||||
process.env['WEB_DIST_DIR'] = previous;
|
||||
}
|
||||
await rm(emptyDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
/** Request paths that belong to the backend, never to the SPA fallback. */
|
||||
const BACKEND_PREFIXES = ['/api', '/mcp', '/socket.io'] as const;
|
||||
|
||||
function isBackendPath(url: string): boolean {
|
||||
// Match on the path only: `/api?x=1` is a backend request, and the query
|
||||
// string must never turn it into an SPA fallback.
|
||||
const pathOnly = url.split('?', 1)[0] ?? url;
|
||||
return BACKEND_PREFIXES.some(
|
||||
(prefix) => pathOnly === prefix || pathOnly.startsWith(`${prefix}/`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the built web SPA bundle (Phase P5 cutover, #1444).
|
||||
*
|
||||
* WEB_DIST_DIR unset: SPA serving is disabled — dev runs the Vite dev server,
|
||||
* which proxies /api and /socket.io here. WEB_DIST_DIR set but not holding a
|
||||
* built bundle: fail at boot, because a gateway configured to serve the UI
|
||||
* silently serving 404s is an outage, not a degraded mode.
|
||||
*
|
||||
* Static files get exact routes (wildcard: false, so nothing shadows the API
|
||||
* routes); every other GET/HEAD outside the backend prefixes falls back to
|
||||
* index.html so client-side routes deep-link correctly.
|
||||
*/
|
||||
export async function mountSpaStatic(app: NestFastifyApplication): Promise<void> {
|
||||
const logger = new Logger('SpaStatic');
|
||||
const distDir = process.env['WEB_DIST_DIR'];
|
||||
if (!distDir) {
|
||||
logger.log('WEB_DIST_DIR not set; SPA serving disabled (dev mode uses the Vite dev server)');
|
||||
return;
|
||||
}
|
||||
|
||||
const root = path.resolve(distDir);
|
||||
const indexFile = path.join(root, 'index.html');
|
||||
if (!existsSync(indexFile)) {
|
||||
throw new Error(`WEB_DIST_DIR is '${distDir}' but '${indexFile}' does not exist`);
|
||||
}
|
||||
|
||||
// Default cache semantics: public, max-age=0 with ETag/Last-Modified, so
|
||||
// every response revalidates (304 when unchanged). Always correct, including
|
||||
// for index.html after a deploy.
|
||||
await app.register(
|
||||
fastifyStatic as never,
|
||||
{
|
||||
root,
|
||||
wildcard: false,
|
||||
index: false,
|
||||
} as never,
|
||||
);
|
||||
|
||||
const fastify = app.getHttpAdapter().getInstance();
|
||||
|
||||
// Files under /assets/ carry a content hash in their name (Vite emits them
|
||||
// that way), so they get long-lived immutable caching: a changed file is a
|
||||
// new URL, never a stale cache hit. An onSend hook rather than the plugin's
|
||||
// `setHeaders` option, because @fastify/static applies its own computed
|
||||
// cache-control (reply.headers) after calling setHeaders, overriding it.
|
||||
fastify.addHook('onSend', (req, reply, payload, done) => {
|
||||
const pathOnly = (req.raw.url ?? '').split('?', 1)[0] ?? '';
|
||||
if (reply.statusCode === 200 && pathOnly.startsWith('/assets/')) {
|
||||
void reply.header('cache-control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
done(null, payload);
|
||||
});
|
||||
|
||||
// A wildcard route, not setNotFoundHandler: Nest installs its own not-found
|
||||
// handler during init and Fastify allows only one. find-my-way matches
|
||||
// most-specific-first, so every declared route (API, static files) wins over
|
||||
// this catch-all; non-GET unmatched requests keep Fastify's stock 404.
|
||||
fastify.get('/*', (req, reply) => {
|
||||
const url = req.raw.url ?? '';
|
||||
const pathOnly = url.split('?', 1)[0] ?? url;
|
||||
if (isBackendPath(url)) {
|
||||
// An unknown backend path is an API 404, never the SPA page.
|
||||
void reply.code(404).send({
|
||||
message: `Route ${req.raw.method ?? 'GET'}:${url} not found`,
|
||||
error: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathOnly === '/assets' || pathOnly.startsWith('/assets/')) {
|
||||
// A missing hashed asset — typically a browser holding a stale
|
||||
// index.html after a deploy — must 404. Falling through to the SPA
|
||||
// fallback would return index.html as the asset body, and the onSend
|
||||
// hook above would stamp it with a year-long immutable cache-control.
|
||||
void reply.code(404).send({
|
||||
message: `Asset ${pathOnly} not found`,
|
||||
error: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// sendFile is decorated by @fastify/static; its type augmentation targets
|
||||
// a different fastify copy in the pnpm tree than the Nest adapter's.
|
||||
(reply as unknown as { sendFile: (file: string) => unknown }).sendFile('index.html');
|
||||
});
|
||||
|
||||
logger.log(`Serving SPA bundle from ${root}`);
|
||||
}
|
||||
@@ -1,18 +1,6 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
import {
|
||||
ChangeCompanyVisibilityDto,
|
||||
ChangeGrantDto,
|
||||
CreateCompanyDto,
|
||||
CreateEstateDto,
|
||||
CreateGrantDto,
|
||||
CreatePlatformProjectDto,
|
||||
DeleteNodeDto,
|
||||
RenameNodeDto,
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy/hierarchy.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
@@ -55,56 +43,6 @@ export const PIPE_GUARDED_DTOS: Array<{
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
{
|
||||
name: 'CreateCompanyDto',
|
||||
target: CreateCompanyDto,
|
||||
properties: ['name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'RenameNodeDto',
|
||||
target: RenameNodeDto,
|
||||
properties: ['name', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeCompanyVisibilityDto',
|
||||
target: ChangeCompanyVisibilityDto,
|
||||
properties: ['visibility', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'DeleteNodeDto',
|
||||
target: DeleteNodeDto,
|
||||
properties: ['idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateEstateDto',
|
||||
target: CreateEstateDto,
|
||||
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreatePlatformProjectDto',
|
||||
target: CreatePlatformProjectDto,
|
||||
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferEstateDto',
|
||||
target: TransferEstateDto,
|
||||
properties: ['destinationCompanyId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferPlatformProjectDto',
|
||||
target: TransferPlatformProjectDto,
|
||||
properties: ['destinationEstateId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateGrantDto',
|
||||
target: CreateGrantDto,
|
||||
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeGrantDto',
|
||||
target: ChangeGrantDto,
|
||||
properties: ['role', 'idempotencyKey'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
|
||||
+28
-20
@@ -1,14 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, ADMIN_USER, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Admin page — admin user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded admin user — skipping admin tests',
|
||||
);
|
||||
test.skip(!url.includes('/chat'), 'No seeded admin user — skipping admin tests');
|
||||
});
|
||||
|
||||
test('admin page loads with the Admin Panel heading', async ({ page }) => {
|
||||
@@ -34,11 +31,15 @@ test.describe('Admin page — admin user', () => {
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name: /system health/i }).click();
|
||||
// Health cards or loading indicator should appear
|
||||
const loadingOrCard = page
|
||||
const hasLoading = await page
|
||||
.getByText(/loading health/i)
|
||||
.or(page.getByText(/database/i))
|
||||
.first();
|
||||
await expect(loadingOrCard).toBeVisible({ timeout: 10_000 });
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasCard = await page
|
||||
.getByText(/database/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasLoading || hasCard).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,19 +47,26 @@ test.describe('Admin page — non-admin user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping non-admin tests',
|
||||
);
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping non-admin tests');
|
||||
});
|
||||
|
||||
test('non-admin visiting /admin never sees the admin panel', async ({ page }) => {
|
||||
test('non-admin visiting /admin sees access denied or is redirected', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
// Wait for the app shell to render (redirect and access-denied views both
|
||||
// keep the sidebar), then assert the panel itself is absent. globalSetup
|
||||
// seeds TEST_USER with role 'member', so this is a real authorization
|
||||
// assertion, not environment-dependent.
|
||||
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('heading', { name: /admin panel/i })).not.toBeVisible();
|
||||
// Either redirected away or shown an access-denied message
|
||||
const onAdmin = page.url().includes('/admin');
|
||||
if (onAdmin) {
|
||||
// Should show some access-denied content rather than the full admin panel
|
||||
const hasPanel = await page
|
||||
.getByRole('heading', { name: /admin panel/i })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// If heading is visible, the guard allowed access (user may have admin role in this env)
|
||||
// — not a failure, just informational
|
||||
if (!hasPanel) {
|
||||
// access denied message, redirect, or guard placeholder
|
||||
const url = page.url();
|
||||
expect(url).toBeTruthy(); // environment-dependent — no hard assertion
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
import { TEST_USER } from './helpers/auth.js';
|
||||
|
||||
// ── Login page ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,14 +49,18 @@ test.describe('Login page', () => {
|
||||
});
|
||||
|
||||
test('redirects to /chat after successful login', async ({ page }) => {
|
||||
// Only meaningful with known-good credentials; against a live environment
|
||||
// this would just probe someone else's user table.
|
||||
test.skip(!REQUIRE_SEEDED_AUTH, 'needs seeded credentials (E2E_REQUIRE_SEEDED_AUTH=1)');
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password').fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
|
||||
// Either reaches /chat or shows an error (if credentials are wrong in this env).
|
||||
// We assert a navigation away from /login, or the alert is shown.
|
||||
await Promise.race([
|
||||
expect(page).toHaveURL(/\/chat/, { timeout: 10_000 }),
|
||||
expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 }),
|
||||
]).catch(() => {
|
||||
// Acceptable — environment may not have seeded credentials
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+26
-19
@@ -1,38 +1,45 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Chat page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
// If login failed (no seeded user in env) we may be on /login — skip
|
||||
const url = page.url();
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
});
|
||||
|
||||
test('chat page loads and shows the conversation area', async ({ page }) => {
|
||||
test('chat page loads and shows the welcome message or conversation list', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
await expect(page.getByRole('heading', { level: 1, name: /chat/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByRole('log', { name: /conversation/i })).toBeVisible();
|
||||
// Either there are conversations listed or the welcome empty-state is shown
|
||||
const hasWelcome = await page
|
||||
.getByRole('heading', { name: /welcome to mosaic chat/i })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasConversationPanel = await page
|
||||
.locator('[data-testid="conversation-list"], nav, aside')
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasWelcome || hasConversationPanel).toBe(true);
|
||||
});
|
||||
|
||||
test('message composer input is visible', async ({ page }) => {
|
||||
test('new conversation button is visible', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
await expect(page.getByLabel('Message')).toBeVisible({ timeout: 10_000 });
|
||||
// "Start new conversation" button or a "+" button in the sidebar
|
||||
const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
||||
await expect(newConvButton).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('command panel lists /new and exposes the run controls', async ({ page }) => {
|
||||
test('clicking new conversation shows a chat input area', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
// Conversations are command-driven: /new starts one via the commands panel.
|
||||
const commandList = page.getByRole('list', { name: /available commands/i });
|
||||
await expect(commandList).toBeVisible({ timeout: 10_000 });
|
||||
await expect(commandList.getByText('/new', { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('Command name')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /run command/i })).toBeVisible();
|
||||
// Find any button that creates a new conversation
|
||||
const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
||||
await newBtn.click();
|
||||
// After creating, a text input for sending messages should appear
|
||||
const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first();
|
||||
await expect(chatInput).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('sidebar navigation is present on chat page', async ({ page }) => {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { FullConfig } from '@playwright/test';
|
||||
import { ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
/**
|
||||
* Seed the E2E users through the gateway's real APIs (#1445, P6).
|
||||
*
|
||||
* On a fresh database (CI boots the gateway on the embedded PGlite path):
|
||||
* 1. POST /api/bootstrap/setup creates ADMIN_USER as the first admin.
|
||||
* 2. The admin signs in and creates TEST_USER via the better-auth admin API.
|
||||
*
|
||||
* Against an environment that already has users (needsSetup=false), seeding is
|
||||
* skipped entirely: the specs keep their own skip-when-login-fails guards, so
|
||||
* a live environment stays usable as a test target without mutation. Under
|
||||
* E2E_REQUIRE_SEEDED_AUTH=1 (CI) that state is instead a hard failure and the
|
||||
* guards are disabled — see helpers/auth.ts.
|
||||
*
|
||||
* On a fresh database, any seeding failure throws and fails the whole run: an
|
||||
* E2E gate whose authenticated suites silently skip would pass while proving
|
||||
* nothing.
|
||||
*/
|
||||
export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
const baseURL = config.projects[0]?.use?.baseURL ?? 'http://localhost:14242';
|
||||
|
||||
const statusRes = await fetch(`${baseURL}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) {
|
||||
throw new Error(`GET /api/bootstrap/status returned ${statusRes.status} — is the gateway up?`);
|
||||
}
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
if (!status.needsSetup) {
|
||||
if (REQUIRE_SEEDED_AUTH) {
|
||||
// CI boots the gateway on a fresh HOME-isolated database, so an
|
||||
// already-populated one means the isolation regressed — refuse to run
|
||||
// against unknown data rather than skip-and-pass.
|
||||
throw new Error(
|
||||
'E2E_REQUIRE_SEEDED_AUTH=1 but the database already has users — gateway HOME isolation regressed?',
|
||||
);
|
||||
}
|
||||
console.info('[e2e setup] users already exist; skipping seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const setupRes = await fetch(`${baseURL}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: ADMIN_USER.name,
|
||||
email: ADMIN_USER.email,
|
||||
password: ADMIN_USER.password,
|
||||
}),
|
||||
});
|
||||
if (!setupRes.ok) {
|
||||
throw new Error(
|
||||
`POST /api/bootstrap/setup failed (${setupRes.status}): ${await setupRes.text()}`,
|
||||
);
|
||||
}
|
||||
console.info(`[e2e setup] bootstrap admin created: ${ADMIN_USER.email}`);
|
||||
|
||||
// better-auth's CSRF protection rejects requests without an Origin header
|
||||
// (403 MISSING_OR_NULL_ORIGIN), so the server-side fetches here send the
|
||||
// gateway's own origin — the same value a browser tab on the SPA would send.
|
||||
const authHeaders = { 'content-type': 'application/json', origin: baseURL };
|
||||
|
||||
const signInRes = await fetch(`${baseURL}/api/auth/sign-in/email`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({ email: ADMIN_USER.email, password: ADMIN_USER.password }),
|
||||
});
|
||||
if (!signInRes.ok) {
|
||||
throw new Error(`admin sign-in failed (${signInRes.status}): ${await signInRes.text()}`);
|
||||
}
|
||||
const cookies = signInRes.headers
|
||||
.getSetCookie()
|
||||
.map((cookie) => cookie.split(';', 1)[0])
|
||||
.join('; ');
|
||||
if (!cookies) {
|
||||
throw new Error('admin sign-in returned no session cookie');
|
||||
}
|
||||
|
||||
const createRes = await fetch(`${baseURL}/api/auth/admin/create-user`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders, cookie: cookies },
|
||||
body: JSON.stringify({
|
||||
name: TEST_USER.name,
|
||||
email: TEST_USER.email,
|
||||
password: TEST_USER.password,
|
||||
role: 'member',
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
throw new Error(
|
||||
`POST /api/auth/admin/create-user failed (${createRes.status}): ${await createRes.text()}`,
|
||||
);
|
||||
}
|
||||
console.info(`[e2e setup] test user created: ${TEST_USER.email}`);
|
||||
}
|
||||
@@ -13,28 +13,11 @@ export const ADMIN_USER = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Set when the database was seeded by global-setup (CI sets it in the
|
||||
* publish.yml e2e step). Seeded credentials MUST work, so login failures are
|
||||
* hard failures and the skip-when-login-fails guards are disabled — otherwise
|
||||
* a login regression would skip every authenticated suite and the gate would
|
||||
* pass while proving nothing. Unset (a live environment used as a test
|
||||
* target), the guards stay on and unseeded credentials skip their suites.
|
||||
*/
|
||||
export const REQUIRE_SEEDED_AUTH = process.env['E2E_REQUIRE_SEEDED_AUTH'] === '1';
|
||||
|
||||
/**
|
||||
* Fill the login form and submit, then wait for the post-login redirect to
|
||||
* /chat. Under REQUIRE_SEEDED_AUTH a missed redirect throws (failing the
|
||||
* test). Otherwise the timeout is swallowed: the page stays on /login and the
|
||||
* callers' `test.skip(...)` guards see that. Without this wait, every guard
|
||||
* read page.url() before the redirect happened and skipped its suite even
|
||||
* when login succeeded (#1445).
|
||||
* Fill the login form and submit. Waits for navigation after success.
|
||||
*/
|
||||
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
const redirect = page.waitForURL(/\/chat/, { timeout: 10_000 });
|
||||
await (REQUIRE_SEEDED_AUTH ? redirect : redirect.catch(() => {}));
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Sidebar navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
});
|
||||
|
||||
test('sidebar shows the Mosaic brand', async ({ page }) => {
|
||||
test('sidebar shows Mosaic brand link', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
// The brand block is a logo image plus "Mosaic / Mission Control" text,
|
||||
// not a link.
|
||||
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible();
|
||||
await expect(page.getByText('Mission Control')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /mosaic/i }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Chat nav link navigates to /chat', async ({ page }) => {
|
||||
@@ -54,12 +48,11 @@ test.describe('Sidebar navigation', () => {
|
||||
|
||||
test('active link is visually highlighted', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
// The sidebar marks the active item with `font-medium` (plus an inline
|
||||
// primary-color style); inactive items get the hover class instead.
|
||||
// The active link should have a distinct class — check that the Chat link
|
||||
// has the active style class (bg-blue-600/20 text-blue-400)
|
||||
const chatLink = page.getByRole('link', { name: /^chat$/i }).first();
|
||||
const projectsLink = page.getByRole('link', { name: /^projects$/i }).first();
|
||||
await expect(chatLink).toHaveClass(/font-medium/);
|
||||
await expect(projectsLink).not.toHaveClass(/font-medium/);
|
||||
const cls = await chatLink.getAttribute('class');
|
||||
expect(cls).toContain('blue');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,23 +60,18 @@ test.describe('Route transitions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
});
|
||||
|
||||
test('navigating chat → projects → settings → chat works without errors', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
await expect(page).toHaveURL(/\/chat/);
|
||||
|
||||
// level: 1 — empty-state h2s ("No projects yet") also match the loose
|
||||
// patterns, and a two-element match is a strict-mode violation.
|
||||
await page.goto('/projects');
|
||||
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(page.getByRole('heading', { level: 1, name: /settings/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
||||
|
||||
await page.goto('/chat');
|
||||
await expect(page).toHaveURL(/\/chat/);
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Projects page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
});
|
||||
|
||||
test('projects page loads with heading', async ({ page }) => {
|
||||
await page.goto('/projects');
|
||||
// level: 1 — the "No projects yet" empty-state h2 also matches /projects/i
|
||||
// and a two-element match is a strict-mode violation.
|
||||
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('shows empty state or project cards when loaded', async ({ page }) => {
|
||||
@@ -25,11 +18,23 @@ test.describe('Projects page', () => {
|
||||
// Wait for loading state to clear
|
||||
await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const cardsOrEmpty = page
|
||||
const hasProjects = await page
|
||||
.locator('[class*="grid"]')
|
||||
.or(page.getByText(/no projects yet/i))
|
||||
.first();
|
||||
await expect(cardsOrEmpty).toBeVisible({ timeout: 10_000 });
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasEmpty = await page
|
||||
.getByText(/no projects yet/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasProjects || hasEmpty).toBe(true);
|
||||
});
|
||||
|
||||
test('shows Active Mission section', async ({ page }) => {
|
||||
await page.goto('/projects');
|
||||
await expect(page.getByRole('heading', { name: /active mission/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('sidebar navigation is present', async ({ page }) => {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Settings page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
});
|
||||
|
||||
test('settings page loads with heading', async ({ page }) => {
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['@mosaicstack/design-tokens'],
|
||||
|
||||
// Enable gzip/brotli compression for all responses.
|
||||
compress: true,
|
||||
|
||||
// Reduce bundle size: disable source maps in production builds.
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
// Image optimisation: allow the gateway origin as an external image source.
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Experimental: enable React compiler for automatic memoisation (Next 15+).
|
||||
// Falls back gracefully if the compiler plugin is not installed.
|
||||
experimental: {
|
||||
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
|
||||
// turbo: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -3,19 +3,22 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"preview": "vite preview",
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build:vite": "vite build",
|
||||
"dev": "next dev -p 3101",
|
||||
"dev:vite": "vite",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "playwright test"
|
||||
"test:e2e": "playwright test",
|
||||
"start": "next start -p 3101"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/design-tokens": "workspace:^",
|
||||
"@mosaicstack/types": "workspace:^",
|
||||
"better-auth": "^1.5.5",
|
||||
"clsx": "^2.1.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright E2E configuration for the Mosaic web SPA.
|
||||
* Playwright E2E configuration for Mosaic web app.
|
||||
*
|
||||
* Assumes the NestJS gateway is already running on http://localhost:14242 and
|
||||
* serving the built SPA bundle (WEB_DIST_DIR pointing at apps/web/dist) — the
|
||||
* same serving path production uses (Phase P5, #1444). Override the target
|
||||
* with PLAYWRIGHT_BASE_URL.
|
||||
*
|
||||
* global-setup seeds the E2E users through the real bootstrap and admin APIs
|
||||
* when the database is empty; against an already-populated environment it
|
||||
* seeds nothing.
|
||||
* Assumes:
|
||||
* - Next.js web app running on http://localhost:3000
|
||||
* - NestJS gateway running on http://localhost:14242
|
||||
*
|
||||
* Run with: pnpm --filter @mosaicstack/web test:e2e
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env['CI'],
|
||||
retries: process.env['CI'] ? 2 : 0,
|
||||
workers: process.env['CI'] ? 1 : undefined,
|
||||
// CI needs the verdict in the step log; the html report is a local tool.
|
||||
reporter: process.env['CI'] ? 'list' : 'html',
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:14242',
|
||||
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
@@ -34,6 +27,6 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
// Do NOT auto-start a server — tests assume the gateway is already running.
|
||||
// Do NOT auto-start the dev server — tests assume it is already running.
|
||||
// webServer is intentionally omitted so tests can run against a live env.
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { GuestGuard } from '@/components/guest-guard';
|
||||
|
||||
export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<GuestGuard>
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg">
|
||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</GuestGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
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(() => {
|
||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
||||
.catch(() => [] as SsoProviderDiscovery[])
|
||||
.then((providers) => setSsoProviders(providers.filter((provider) => provider.configured)));
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const email = form.get('email') as string;
|
||||
const password = form.get('password') as string;
|
||||
|
||||
const result = await signIn.email({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Sign in failed');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/chat');
|
||||
}
|
||||
|
||||
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 (err: unknown) {
|
||||
setError(err instanceof Error ? err.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>
|
||||
)}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<SsoProviderButtons
|
||||
providers={ssoProviders}
|
||||
loadingProviderId={ssoLoadingProviderId}
|
||||
onOidcSignIn={(providerId) => {
|
||||
void handleSsoSignIn(providerId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-blue-400 hover:text-blue-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { signUp } from '@/lib/auth-client';
|
||||
|
||||
export default function RegisterPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const name = form.get('name') as string;
|
||||
const email = form.get('email') as string;
|
||||
const password = form.get('password') as string;
|
||||
|
||||
const result = await signUp.email({ name, email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Registration failed');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/chat');
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
<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 href="/login" className="text-blue-400 hover:text-blue-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { AdminRoleGuard } from '@/components/admin-role-guard';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface UserDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
banned: boolean;
|
||||
banReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface UserListDto {
|
||||
users: UserDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ServiceStatusDto {
|
||||
status: 'ok' | 'error';
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ProviderStatusDto {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
interface HealthStatusDto {
|
||||
status: 'ok' | 'degraded' | 'error';
|
||||
database: ServiceStatusDto;
|
||||
cache: ServiceStatusDto;
|
||||
agentPool: { activeSessions: number };
|
||||
providers: ProviderStatusDto[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
return (
|
||||
<AdminRoleGuard>
|
||||
<AdminContent />
|
||||
</AdminRoleGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminContent(): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-surface-border">
|
||||
{(['users', 'health'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium capitalize transition-colors',
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-blue-500 text-blue-400'
|
||||
: 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
{tab === 'users' ? 'User Management' : 'System Health'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Users Tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function UsersTab(): React.ReactElement {
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api<UserListDto>('/api/admin/users');
|
||||
setUsers(data.users);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
async function handleRoleToggle(user: UserDto): Promise<void> {
|
||||
const newRole = user.role === 'admin' ? 'member' : 'admin';
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/role`, {
|
||||
method: 'PATCH',
|
||||
body: { role: newRole },
|
||||
});
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to update role');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBanToggle(user: UserDto): Promise<void> {
|
||||
const endpoint = user.banned ? 'unban' : 'ban';
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to update ban status');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(user: UserDto): Promise<void> {
|
||||
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete user');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-text-muted">Loading users...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadUsers()}
|
||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-muted">{users.length} user(s)</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
+ New User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateUserForm
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onCreated={() => {
|
||||
setShowCreate(false);
|
||||
void loadUsers();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
||||
<p className="text-sm text-text-muted">No users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
||||
<th className="px-4 py-2 font-medium">Name / Email</th>
|
||||
<th className="px-4 py-2 font-medium">Role</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
|
||||
<th className="px-4 py-2 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-text-primary">{user.name}</div>
|
||||
<div className="text-xs text-text-muted">{user.email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-surface-elevated text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 md:table-cell">
|
||||
{user.banned ? (
|
||||
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||
Banned
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRoleToggle(user)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
|
||||
>
|
||||
{user.role === 'admin' ? 'Demote' : 'Promote'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBanToggle(user)}
|
||||
className={cn(
|
||||
'text-xs',
|
||||
user.banned
|
||||
? 'text-green-400 hover:text-green-300'
|
||||
: 'text-yellow-400 hover:text-yellow-300',
|
||||
)}
|
||||
>
|
||||
{user.banned ? 'Unban' : 'Ban'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(user)}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create User Form ──────────────────────────────────────────────────────────
|
||||
|
||||
interface CreateUserFormProps {
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('member');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: { name, email, password, role },
|
||||
});
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Role</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Health Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function HealthTab(): React.ReactElement {
|
||||
const [health, setHealth] = useState<HealthStatusDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api<HealthStatusDto>('/api/admin/health');
|
||||
setHealth(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load health');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHealth();
|
||||
}, [loadHealth]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-text-muted">Loading health status...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadHealth()}
|
||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!health) return <></>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={health.status} />
|
||||
<span className="text-sm text-text-muted">
|
||||
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadHealth()}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* Database */}
|
||||
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
|
||||
{health.database.latencyMs !== undefined && (
|
||||
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
|
||||
)}
|
||||
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
|
||||
</HealthCard>
|
||||
|
||||
{/* Cache */}
|
||||
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
|
||||
{health.cache.latencyMs !== undefined && (
|
||||
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
|
||||
)}
|
||||
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
|
||||
</HealthCard>
|
||||
|
||||
{/* Agent Pool */}
|
||||
<HealthCard title="Agent Pool" status="ok">
|
||||
<p className="text-xs text-text-muted">
|
||||
Active sessions: {health.agentPool.activeSessions}
|
||||
</p>
|
||||
</HealthCard>
|
||||
|
||||
{/* Providers */}
|
||||
<HealthCard
|
||||
title="LLM Providers"
|
||||
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
|
||||
>
|
||||
{health.providers.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">No providers configured</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{health.providers.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between text-xs">
|
||||
<span className="text-text-secondary">{p.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-0.5',
|
||||
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
|
||||
)}
|
||||
>
|
||||
{p.available ? `${p.modelCount} models` : 'unavailable'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</HealthCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helper Components ─────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
|
||||
const map = {
|
||||
ok: 'bg-green-500/20 text-green-400',
|
||||
degraded: 'bg-yellow-500/20 text-yellow-400',
|
||||
error: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
return (
|
||||
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface HealthCardProps {
|
||||
title: string;
|
||||
status: 'ok' | 'error';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
||||
<span
|
||||
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { destroySocket, getSocket } from '@/lib/socket';
|
||||
import type { Conversation, Message } from '@/lib/types';
|
||||
import {
|
||||
ConversationSidebar,
|
||||
type ConversationSidebarRef,
|
||||
} from '@/components/chat/conversation-sidebar';
|
||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
||||
import { ChatInput } from '@/components/chat/chat-input';
|
||||
import { StreamingMessage } from '@/components/chat/streaming-message';
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
inputTypes: ('text' | 'image')[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
}
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export default function ChatPage(): React.ReactElement {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
||||
|
||||
// Track the active conversation ID in a ref so socket event handlers always
|
||||
// see the current value without needing to be re-registered.
|
||||
const activeIdRef = useRef<string | null>(null);
|
||||
activeIdRef.current = activeId;
|
||||
|
||||
// Accumulate streamed text in a ref so agent:end can read the full content
|
||||
// without stale-closure issues.
|
||||
const streamingTextRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
|
||||
if (savedState !== null) {
|
||||
setIsSidebarOpen(savedState === 'true');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
api<ProviderInfo[]>('/api/providers')
|
||||
.then((providers) => {
|
||||
const availableModels = providers
|
||||
.filter((provider) => provider.available)
|
||||
.flatMap((provider) => provider.models);
|
||||
setModels(availableModels);
|
||||
setSelectedModelId((current) => current || availableModels[0]?.id || '');
|
||||
})
|
||||
.catch(() => {
|
||||
setModels([]);
|
||||
setSelectedModelId('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load messages when active conversation changes
|
||||
useEffect(() => {
|
||||
if (!activeId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
// Clear streaming state when switching conversations
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
||||
.then(setMessages)
|
||||
.catch(() => {});
|
||||
}, [activeId]);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingText]);
|
||||
|
||||
// Socket.io setup — connect once for the page lifetime
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
|
||||
function onAgentStart(data: { conversationId: string }): void {
|
||||
// Only update state if the event belongs to the currently viewed conversation
|
||||
if (activeIdRef.current !== data.conversationId) return;
|
||||
setIsStreaming(true);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
}
|
||||
|
||||
function onAgentText(data: { conversationId: string; text: string }): void {
|
||||
if (activeIdRef.current !== data.conversationId) return;
|
||||
streamingTextRef.current += data.text;
|
||||
setStreamingText((prev) => prev + data.text);
|
||||
}
|
||||
|
||||
function onAgentEnd(data: { conversationId: string }): void {
|
||||
if (activeIdRef.current !== data.conversationId) return;
|
||||
const finalText = streamingTextRef.current;
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
// Append the completed assistant message to the local message list.
|
||||
// The Pi agent session is in-memory so the assistant response is not
|
||||
// persisted to the DB — we build the local UI state instead.
|
||||
if (finalText) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
conversationId: data.conversationId,
|
||||
role: 'assistant' as const,
|
||||
content: finalText,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
sidebarRef.current?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function onError(data: { error: string; conversationId?: string }): void {
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId: data.conversationId ?? '',
|
||||
role: 'system' as const,
|
||||
content: `Error: ${data.error}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
socket.on('agent:start', onAgentStart);
|
||||
socket.on('agent:text', onAgentText);
|
||||
socket.on('agent:end', onAgentEnd);
|
||||
socket.on('error', onError);
|
||||
|
||||
// Connect if not already connected
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket.off('agent:start', onAgentStart);
|
||||
socket.off('agent:text', onAgentText);
|
||||
socket.off('agent:end', onAgentEnd);
|
||||
socket.off('error', onError);
|
||||
// Fully tear down the socket when the chat page unmounts so we get a
|
||||
// fresh authenticated connection next time the page is visited.
|
||||
destroySocket();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNewConversation = useCallback(async (projectId?: string | null) => {
|
||||
const conv = await api<Conversation>('/api/conversations', {
|
||||
method: 'POST',
|
||||
body: { title: 'New conversation', projectId: projectId ?? null },
|
||||
});
|
||||
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
|
||||
setActiveId(conv.id);
|
||||
setMessages([]);
|
||||
setIsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string, options?: { modelId?: string }) => {
|
||||
let convId = activeId;
|
||||
|
||||
// Auto-create conversation if none selected
|
||||
if (!convId) {
|
||||
const autoTitle = content.slice(0, 60);
|
||||
const conv = await api<Conversation>('/api/conversations', {
|
||||
method: 'POST',
|
||||
body: { title: autoTitle },
|
||||
});
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
setActiveId(conv.id);
|
||||
convId = conv.id;
|
||||
} else if (messages.length === 0) {
|
||||
// Auto-title the initial placeholder conversation from the first user message.
|
||||
const autoTitle = content.slice(0, 60);
|
||||
api<Conversation>(`/api/conversations/${convId}`, {
|
||||
method: 'PATCH',
|
||||
body: { title: autoTitle },
|
||||
})
|
||||
.then(() => sidebarRef.current?.refresh())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Optimistic user message in local UI state
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `user-${Date.now()}`,
|
||||
conversationId: convId,
|
||||
role: 'user' as const,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
// Persist the user message to the DB so conversation history is
|
||||
// available when the page is reloaded or a new session starts.
|
||||
api<Message>(`/api/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
body: { role: 'user', content },
|
||||
}).catch(() => {
|
||||
// Non-fatal: the agent can still process the message even if
|
||||
// REST persistence fails.
|
||||
});
|
||||
|
||||
// Send to WebSocket — gateway creates/resumes the agent session and
|
||||
// streams the response back via agent:start / agent:text / agent:end.
|
||||
const socket = getSocket();
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
socket.emit('message', {
|
||||
conversationId: convId,
|
||||
content,
|
||||
modelId: (options?.modelId ?? selectedModelId) || undefined,
|
||||
});
|
||||
},
|
||||
[activeId, messages, selectedModelId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
|
||||
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
|
||||
>
|
||||
<ConversationSidebar
|
||||
ref={sidebarRef}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
currentConversationId={activeId}
|
||||
onSelectConversation={(conversationId) => {
|
||||
setActiveId(conversationId);
|
||||
setMessages([]);
|
||||
if (conversationId && window.innerWidth < 768) {
|
||||
setIsSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onNewConversation={(projectId) => {
|
||||
void handleNewConversation(projectId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex items-center gap-3 border-b px-4 py-3"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSidebarOpen((open) => !open)}
|
||||
className="rounded-lg border p-2 transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||
Mosaic Chat
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeId ? (
|
||||
<>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{isStreaming && <StreamingMessage text={streamingText} />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
isStreaming={isStreaming}
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onModelChange={setSelectedModelId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<div
|
||||
className="max-w-md rounded-2xl border px-8 py-10 text-center"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
|
||||
Welcome to Mosaic Chat
|
||||
</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Select a conversation or start a new one
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleNewConversation();
|
||||
}}
|
||||
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ background: 'var(--primary)' }}
|
||||
>
|
||||
Start new conversation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AppShell } from '@/components/layout/app-shell';
|
||||
import { AuthGuard } from '@/components/auth-guard';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<AppShell>{children}</AppShell>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
||||
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';
|
||||
|
||||
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
||||
|
||||
const statusColors: 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',
|
||||
};
|
||||
|
||||
interface TabButtonProps {
|
||||
id: Tab;
|
||||
label: string;
|
||||
activeTab: Tab;
|
||||
onClick: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.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 default function ProjectDetailPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = typeof params['id'] === 'string' ? params['id'] : '';
|
||||
|
||||
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) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
Promise.all([
|
||||
api<Project>(`/api/projects/${id}`),
|
||||
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
||||
api<Task[]>(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]),
|
||||
])
|
||||
.then(([proj, allMissions, tks]) => {
|
||||
setProject(proj);
|
||||
setMissions(allMissions.filter((m) => m.projectId === id));
|
||||
setTasks(tks);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message ?? 'Failed to load project');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleTaskClick = useCallback((task: Task) => {
|
||||
setSelectedTask(task);
|
||||
}, []);
|
||||
|
||||
const handleCloseTaskModal = useCallback(() => {
|
||||
setSelectedTask(null);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-text-muted">Loading project...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-error">{error ?? 'Project not found'}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/projects')}
|
||||
className="mt-4 text-sm text-text-muted underline hover:text-text-secondary"
|
||||
>
|
||||
Back to projects
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter);
|
||||
|
||||
const prdContent = getPrdContent(project);
|
||||
const hasPrd = Boolean(prdContent);
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
||||
{ id: 'missions', label: `Missions (${missions.length})` },
|
||||
...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/projects')}
|
||||
className="hover:text-text-secondary"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary">{project.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="mb-6 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',
|
||||
statusColors[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>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
||||
{new Date(project.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<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((t) => t.status === 'done').length)}
|
||||
valueClass="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={String(tasks.filter((t) => t.status === 'in-progress').length)}
|
||||
valueClass="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={String(tasks.filter((t) => t.status === 'blocked').length)}
|
||||
valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<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>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
||||
)}
|
||||
|
||||
{activeTab === 'tasks' && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<TaskStatusSummary
|
||||
tasks={tasks}
|
||||
activeFilter={taskFilter}
|
||||
onFilterChange={setTaskFilter}
|
||||
/>
|
||||
</div>
|
||||
<TaskListView tasks={filteredTasks} onTaskClick={handleTaskClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'missions' && <MissionTimeline missions={missions} />}
|
||||
|
||||
{activeTab === 'prd' && prdContent && (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||
<PrdViewer content={prdContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task detail modal */}
|
||||
{selectedTask && <TaskDetailModal task={selectedTask} onClose={handleCloseTaskModal} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewTabProps {
|
||||
project: Project;
|
||||
missions: Mission[];
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement {
|
||||
const recentTasks = [...tasks]
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Recent tasks */}
|
||||
<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) => (
|
||||
<TaskSummaryRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Mission summary */}
|
||||
<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>
|
||||
|
||||
{/* Metadata */}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
function TaskSummaryRow({ task }: { task: Task }): React.ReactElement {
|
||||
return (
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClass?: string;
|
||||
}): React.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;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Project } from '@/lib/types';
|
||||
import { ProjectCard } from '@/components/projects/project-card';
|
||||
|
||||
export default function ProjectsPage(): React.ReactElement {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
api<Project[]>('/api/projects')
|
||||
.then(setProjects)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleProjectClick = useCallback(
|
||||
(project: Project) => {
|
||||
router.push(`/projects/${project.id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
||||
</div>
|
||||
|
||||
{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={handleProjectClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mission status section */}
|
||||
<MissionStatus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionStatus(): React.ReactElement {
|
||||
const [mission, setMission] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api<Record<string, unknown>>('/api/coord/status')
|
||||
.then(setMission)
|
||||
.catch(() => setMission(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Loading mission status...</p>
|
||||
) : !mission ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
||||
<p className="text-sm text-text-muted">No active mission detected</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="Mission" value={String(mission['missionId'] ?? 'Unknown')} />
|
||||
<StatCard label="Phase" value={String(mission['currentPhase'] ?? '—')} />
|
||||
<StatCard
|
||||
label="Tasks"
|
||||
value={`${mission['completedTasks'] ?? 0} / ${mission['totalTasks'] ?? 0}`}
|
||||
/>
|
||||
<StatCard label="Status" value={String(mission['status'] ?? '—')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-elevated p-3">
|
||||
<p className="text-xs text-text-muted">{label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, useSession } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
inputTypes: ('text' | 'image')[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
}
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
interface TestConnectionResult {
|
||||
providerId: string;
|
||||
reachable: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
discoveredModels?: string[];
|
||||
}
|
||||
|
||||
type TestState = 'idle' | 'testing' | 'success' | 'error';
|
||||
|
||||
interface ProviderTestStatus {
|
||||
state: TestState;
|
||||
result?: TestConnectionResult;
|
||||
}
|
||||
|
||||
interface Preference {
|
||||
key: string;
|
||||
value: unknown;
|
||||
category: string;
|
||||
}
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
||||
const p = prefs.find((x) => x.key === key);
|
||||
if (p === undefined) return fallback;
|
||||
return p.value as T;
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'profile', label: 'Profile' },
|
||||
{ id: 'appearance', label: 'Appearance' },
|
||||
{ id: 'notifications', label: 'Notifications' },
|
||||
{ id: 'providers', label: 'Providers' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-surface-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-b-2 border-accent text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'profile' && <ProfileTab session={session} />}
|
||||
{activeTab === 'appearance' && <AppearanceTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'providers' && <ProvidersTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Profile Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileTab({
|
||||
session,
|
||||
}: {
|
||||
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
|
||||
}): React.ReactElement {
|
||||
const [name, setName] = useState(session?.user.name ?? '');
|
||||
const [image, setImage] = useState(session?.user.image ?? '');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Sync from session when it loads
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
setName(session.user.name ?? '');
|
||||
setImage(session.user.image ?? '');
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const result = await authClient.updateUser({ name, image: image || null });
|
||||
if (result.error) {
|
||||
setErrorMsg(result.error.message ?? 'Failed to update profile');
|
||||
setSaveState('error');
|
||||
return;
|
||||
}
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
||||
setErrorMsg(message);
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
|
||||
<FormField label="Display Name" id="profile-name">
|
||||
<input
|
||||
id="profile-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
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-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Email" id="profile-email">
|
||||
<input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={session?.user.email ?? ''}
|
||||
disabled
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Avatar URL" id="profile-image">
|
||||
<input
|
||||
id="profile-image"
|
||||
type="url"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
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-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<SaveButton state={saveState} onClick={handleSave} />
|
||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Appearance Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function AppearanceTab(): React.ReactElement {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [defaultModel, setDefaultModel] = useState('');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
||||
.catch(() => [] as Preference[])
|
||||
.then((p) => {
|
||||
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
|
||||
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
|
||||
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
await Promise.all([
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
|
||||
}),
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'ui.sidebar_collapsed',
|
||||
value: sidebarCollapsed,
|
||||
category: 'appearance',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
...(defaultModel
|
||||
? [
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'ui.default_model',
|
||||
value: defaultModel,
|
||||
category: 'appearance',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
|
||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
||||
{/* Theme */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
|
||||
<div className="flex gap-3">
|
||||
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTheme(t)}
|
||||
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
|
||||
theme === t
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar collapsed default */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
|
||||
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
|
||||
</div>
|
||||
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
|
||||
</div>
|
||||
|
||||
{/* Default model */}
|
||||
<FormField label="Default Model" id="default-model">
|
||||
<input
|
||||
id="default-model"
|
||||
type="text"
|
||||
value={defaultModel}
|
||||
onChange={(e) => setDefaultModel(e.target.value)}
|
||||
placeholder="e.g. ollama/llama3.2"
|
||||
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-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Model ID to pre-select for new conversations.
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<SaveButton state={saveState} onClick={handleSave} />
|
||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Notifications Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationsTab(): React.ReactElement {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
|
||||
const [emailMentions, setEmailMentions] = useState(true);
|
||||
const [emailDigest, setEmailDigest] = useState(false);
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=communication')
|
||||
.catch(() => [] as Preference[])
|
||||
.then((p) => {
|
||||
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
|
||||
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
|
||||
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
await Promise.all([
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'notify.email_agent_complete',
|
||||
value: emailAgentComplete,
|
||||
category: 'communication',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'notify.email_mentions',
|
||||
value: emailMentions,
|
||||
category: 'communication',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'notify.email_digest',
|
||||
value: emailDigest,
|
||||
category: 'communication',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
|
||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
||||
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
|
||||
|
||||
<NotifyRow
|
||||
label="Agent task completed"
|
||||
description="Email when an agent finishes a task"
|
||||
checked={emailAgentComplete}
|
||||
onChange={setEmailAgentComplete}
|
||||
/>
|
||||
<NotifyRow
|
||||
label="Mentions"
|
||||
description="Email when you are mentioned in a conversation"
|
||||
checked={emailMentions}
|
||||
onChange={setEmailMentions}
|
||||
/>
|
||||
<NotifyRow
|
||||
label="Weekly digest"
|
||||
description="Weekly summary of activity"
|
||||
checked={emailDigest}
|
||||
onChange={setEmailDigest}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<SaveButton state={saveState} onClick={handleSave} />
|
||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Providers Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function ProvidersTab(): React.ReactElement {
|
||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ssoLoading, setSsoLoading] = useState(true);
|
||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
||||
|
||||
useEffect(() => {
|
||||
api<ProviderInfo[]>('/api/providers')
|
||||
.catch(() => [] as ProviderInfo[])
|
||||
.then((p) => setProviders(p))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
||||
.catch(() => [] as SsoProviderDiscovery[])
|
||||
.then((providers) => setSsoProviders(providers))
|
||||
.finally(() => setSsoLoading(false));
|
||||
}, []);
|
||||
|
||||
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
||||
setTestStatuses((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { state: 'testing' },
|
||||
}));
|
||||
try {
|
||||
const result = await api<TestConnectionResult>('/api/providers/test', {
|
||||
method: 'POST',
|
||||
body: { providerId },
|
||||
});
|
||||
setTestStatuses((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { state: result.reachable ? 'success' : 'error', result },
|
||||
}));
|
||||
} catch {
|
||||
setTestStatuses((prev) => ({
|
||||
...prev,
|
||||
[providerId]: {
|
||||
state: 'error',
|
||||
result: { providerId, reachable: false, error: 'Request failed' },
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const defaultModel: ModelInfo | undefined = providers
|
||||
.flatMap((p) => p.models)
|
||||
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
|
||||
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
No providers configured. Set{' '}
|
||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||
OLLAMA_BASE_URL
|
||||
</code>{' '}
|
||||
or{' '}
|
||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||
MOSAIC_CUSTOM_PROVIDERS
|
||||
</code>{' '}
|
||||
to add providers.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
defaultModel={defaultModel}
|
||||
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
||||
onTest={() => void testConnection(provider.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared UI Components ─────────────────────────────────────────────────────
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
|
||||
checked ? 'bg-accent' : 'bg-surface-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
checked ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{label}</p>
|
||||
<p className="text-xs text-text-muted">{description}</p>
|
||||
</div>
|
||||
<Toggle checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton({
|
||||
state,
|
||||
onClick,
|
||||
}: {
|
||||
state: SaveState;
|
||||
onClick: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={state === 'saving'}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Provider Card (from original page) ──────────────────────────────────────
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: ProviderInfo;
|
||||
defaultModel: ModelInfo | undefined;
|
||||
testStatus: ProviderTestStatus;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
function ProviderCard({
|
||||
provider,
|
||||
defaultModel,
|
||||
testStatus,
|
||||
onTest,
|
||||
}: ProviderCardProps): React.ReactElement {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderAvatar id={provider.id} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
|
||||
<ProviderStatusBadge available={provider.available} />
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TestConnectionButton status={testStatus} onTest={onTest} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse models' : 'Expand models'}
|
||||
>
|
||||
{expanded ? '▲ Hide' : '▼ Models'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result banner */}
|
||||
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
|
||||
<TestResultBanner result={testStatus.result} />
|
||||
)}
|
||||
|
||||
{/* Model list */}
|
||||
{expanded && (
|
||||
<div className="border-t border-surface-border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
|
||||
<th className="px-4 py-2 font-medium">Model</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
||||
<th className="px-4 py-2 font-medium">Default</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{provider.models.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
isDefault={
|
||||
defaultModel?.id === model.id && defaultModel?.provider === model.provider
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelRowProps {
|
||||
model: ModelInfo;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
|
||||
return (
|
||||
<tr className="border-t border-surface-border">
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-sm text-text-primary">{model.name}</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-2 md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<CapabilityBadge label="chat" />
|
||||
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
|
||||
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||
{formatContext(model.contextWindow)}
|
||||
</td>
|
||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||
{model.cost.input === 0 && model.cost.output === 0
|
||||
? 'free'
|
||||
: `$${model.cost.input} / $${model.cost.output}`}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{isDefault && (
|
||||
<span
|
||||
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
|
||||
title="Default model used for new sessions"
|
||||
>
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
|
||||
const letter = id.charAt(0).toUpperCase();
|
||||
return (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{available ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TestConnectionButtonProps {
|
||||
status: ProviderTestStatus;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
|
||||
const isTesting = status.state === 'testing';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Test connection"
|
||||
>
|
||||
{isTesting ? (
|
||||
<span className="text-text-muted">Testing…</span>
|
||||
) : status.state === 'success' ? (
|
||||
<span className="text-success">✓ Reachable</span>
|
||||
) : status.state === 'error' ? (
|
||||
<span className="text-error">✗ Unreachable</span>
|
||||
) : (
|
||||
<span className="text-text-muted">Test</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-2 text-xs ${
|
||||
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
|
||||
}`}
|
||||
>
|
||||
{result.reachable ? (
|
||||
<>
|
||||
Connected
|
||||
{result.latencyMs !== undefined && (
|
||||
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
|
||||
)}
|
||||
{result.discoveredModels && result.discoveredModels.length > 0 && (
|
||||
<span className="ml-2 opacity-70">
|
||||
— {result.discoveredModels.length} model
|
||||
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityBadge({
|
||||
label,
|
||||
color = 'default',
|
||||
}: {
|
||||
label: string;
|
||||
color?: 'default' | 'purple' | 'blue';
|
||||
}): React.ReactElement {
|
||||
const colorClass =
|
||||
color === 'purple'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: color === 'blue'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-surface-elevated text-text-muted';
|
||||
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function formatContext(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
||||
return String(tokens);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
export default function TasksPage(): React.ReactElement {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [view, setView] = useState<ViewMode>('kanban');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api<Task[]>('/api/tasks')
|
||||
.then(setTasks)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleTaskClick = useCallback((task: Task) => {
|
||||
// Task detail view will be added in future iteration
|
||||
console.log('Task clicked:', task.id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
||||
) : view === 'kanban' ? (
|
||||
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
|
||||
) : (
|
||||
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
const params = useParams<{ provider: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [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
|
||||
href="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Mosaic',
|
||||
description: 'Mosaic Stack Dashboard',
|
||||
};
|
||||
|
||||
function themeScript(): string {
|
||||
return `
|
||||
(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');
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<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 dangerouslySetInnerHTML={{ __html: themeScript() }} />
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage(): never {
|
||||
redirect('/chat');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AdminRoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminRoleGuard({ children }: AdminRoleGuardProps): React.ReactElement | null {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const user = session?.user as
|
||||
| (NonNullable<typeof session>['user'] & { role?: string })
|
||||
| undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.replace('/login');
|
||||
} else if (!isPending && session && user?.role !== 'admin') {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [isPending, session, user?.role, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || user?.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps): React.ReactElement | null {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [isPending, session, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ModelInfo } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Conversation } from '@/lib/types';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface StreamingMessageProps {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface GuestGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Redirects authenticated users away from auth pages. */
|
||||
export function GuestGuard({ children }: GuestGuardProps): React.ReactElement | null {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session) {
|
||||
router.replace('/chat');
|
||||
}
|
||||
}, [isPending, session, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AppHeaderProps {
|
||||
conversationTitle?: string | null;
|
||||
isSidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
|
||||
|
||||
export function AppHeader({
|
||||
conversationTitle,
|
||||
isSidebarOpen,
|
||||
onToggleSidebar,
|
||||
}: AppHeaderProps): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [currentTime, setCurrentTime] = useState('');
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [theme, setTheme] = useState<ThemeMode>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
function updateTime(): void {
|
||||
setCurrentTime(
|
||||
new Date().toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
updateTime();
|
||||
const interval = window.setInterval(updateTime, 60_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/version.json')
|
||||
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
|
||||
}
|
||||
})
|
||||
.catch(() => setVersion(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, []);
|
||||
|
||||
const handleThemeToggle = useCallback(() => {
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, [theme]);
|
||||
|
||||
const handleSignOut = useCallback(async (): Promise<void> => {
|
||||
await signOut();
|
||||
window.location.href = '/login';
|
||||
}, []);
|
||||
|
||||
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
|
||||
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-20 border-b backdrop-blur-xl"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle conversation sidebar"
|
||||
aria-expanded={isSidebarOpen}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<Link href="/chat" className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
|
||||
}}
|
||||
>
|
||||
M
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
|
||||
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
|
||||
{currentTime || '--:--'}
|
||||
</div>
|
||||
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{conversationTitle?.trim() || 'New Session'}
|
||||
</div>
|
||||
{version ? (
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
v{version}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<ShortcutHint label="⌘/" text="focus" />
|
||||
<ShortcutHint label="⌘K" text="focus" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleThemeToggle}
|
||||
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? '☀︎' : '☾'}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="Open user menu"
|
||||
>
|
||||
{session?.user.image ? (
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={userLabel}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<div
|
||||
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
|
||||
{session?.user.email ? (
|
||||
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSignOut()}
|
||||
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(label: string): string {
|
||||
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
|
||||
if (words.length === 0) return 'M';
|
||||
return words.map((word) => word.charAt(0).toUpperCase()).join('');
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeMode): void {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
root.setAttribute('data-theme', 'light');
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { SidebarProvider, useSidebar } from './sidebar-context';
|
||||
import { Sidebar } from './sidebar';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
interface SidebarContextValue {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { MosaicLogo } from '@/components/ui/mosaic-logo';
|
||||
import { useSidebar } from './sidebar-context';
|
||||
@@ -96,7 +99,7 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
export function Sidebar(): React.ReactElement {
|
||||
const { pathname } = useLocation();
|
||||
const pathname = usePathname();
|
||||
const { mobileOpen, setMobileOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
@@ -134,7 +137,7 @@ export function Sidebar(): React.ReactElement {
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all duration-150',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '@/providers/theme-provider';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
import { ThemeToggle } from './theme-toggle';
|
||||
import { useSidebar } from './sidebar-context';
|
||||
@@ -20,12 +22,12 @@ function MenuIcon(): React.JSX.Element {
|
||||
|
||||
export function Topbar(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
||||
|
||||
async function handleSignOut(): Promise<void> {
|
||||
await signOut();
|
||||
navigate('/login', { replace: true });
|
||||
router.replace('/login');
|
||||
}
|
||||
|
||||
function handleSidebarToggle(): void {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, MissionStatus } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
interface PrdViewerProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Project } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { Task, TaskStatus } from '@/lib/types';
|
||||
import { TaskCard } from './task-card';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task, TaskStatus } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export interface MosaicLogoProps {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { createAppRouter } from '@/routes';
|
||||
import '@/globals.css';
|
||||
import '@/app/globals.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
+16
-30
@@ -16,15 +16,6 @@ import { TasksPage } from '@/spa/pages/tasks';
|
||||
import { SettingsPage } from '@/spa/pages/settings';
|
||||
import { AdminPage } from '@/spa/pages/admin';
|
||||
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { AppShell } from '@/components/layout/app-shell';
|
||||
|
||||
function DashboardLayout(): ReactElement {
|
||||
return (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function GuestLayout(): ReactElement {
|
||||
return (
|
||||
@@ -53,28 +44,23 @@ export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
{
|
||||
element: <DashboardLayout />,
|
||||
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: <SettingsPage /> },
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
],
|
||||
path: '/projects',
|
||||
element: <ProjectsPage />,
|
||||
errorElement: <ProjectsRouteErrorBoundary />,
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
element: <ProjectDetailPage />,
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -74,11 +73,7 @@ describe('ChatRouteErrorBoundary', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
@@ -11,7 +11,6 @@ vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
function Boom(): never {
|
||||
@@ -72,11 +71,7 @@ describe('resource route error boundaries', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
@@ -57,16 +57,6 @@ function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
||||
return p.value as T;
|
||||
}
|
||||
|
||||
// The reset must not outlive the tab: an uncleared setTimeout fires into a
|
||||
// torn-down environment (unmount, or jsdom teardown under vitest).
|
||||
function useSavedBadgeReset(saveState: SaveState, setSaveState: (s: SaveState) => void): void {
|
||||
useEffect(() => {
|
||||
if (saveState !== 'saved') return undefined;
|
||||
const timer = setTimeout(() => setSaveState('idle'), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [saveState, setSaveState]);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
@@ -121,7 +111,6 @@ function ProfileTab({
|
||||
const [image, setImage] = useState(session?.user.image ?? '');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
useSavedBadgeReset(saveState, setSaveState);
|
||||
|
||||
// Sync from session when it loads
|
||||
useEffect(() => {
|
||||
@@ -142,6 +131,7 @@ function ProfileTab({
|
||||
return;
|
||||
}
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
||||
setErrorMsg(message);
|
||||
@@ -204,7 +194,6 @@ function AppearanceTab(): React.ReactElement {
|
||||
const [defaultModel, setDefaultModel] = useState('');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
useSavedBadgeReset(saveState, setSaveState);
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
||||
@@ -250,6 +239,7 @@ function AppearanceTab(): React.ReactElement {
|
||||
: []),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
@@ -333,7 +323,6 @@ function NotificationsTab(): React.ReactElement {
|
||||
const [emailDigest, setEmailDigest] = useState(false);
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
useSavedBadgeReset(saveState, setSaveState);
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=communication')
|
||||
@@ -380,6 +369,7 @@ function NotificationsTab(): React.ReactElement {
|
||||
}),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
|
||||
@@ -21,23 +21,3 @@ for (const target of [globalThis, window]) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// jsdom (v29) does not implement window.matchMedia; the sidebar layout uses it
|
||||
// for its mobile breakpoint. Minimal always-desktop stub.
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "vitest.config.ts"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
||||
@@ -10,8 +10,6 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/appservice/package.json ./apps/appservice/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
||||
|
||||
@@ -8,16 +8,14 @@ WORKDIR /app
|
||||
# Copy workspace manifests first for layer-cached install
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/gateway/package.json ./apps/gateway/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
# Build gateway, the web SPA bundle it serves (#1444), and all of their
|
||||
# workspace dependencies via the turbo dependency graph
|
||||
RUN pnpm turbo run build --filter @mosaicstack/gateway... --filter @mosaicstack/web...
|
||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||
RUN pnpm turbo run build --filter @mosaicstack/gateway...
|
||||
# Produce a self-contained deploy artifact: flat node_modules, no pnpm symlinks
|
||||
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
|
||||
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
||||
@@ -40,9 +38,6 @@ 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;
|
||||
# copy from builder explicitly as belt-and-suspenders
|
||||
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
||||
# The built web SPA bundle; served by the gateway (apps/gateway/src/spa/serve-spa.ts)
|
||||
COPY --chown=node:node --from=builder /app/apps/web/dist ./web-dist
|
||||
ENV WEB_DIST_DIR=/app/web-dist
|
||||
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
||||
EXPOSE 14242
|
||||
USER node
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm --filter @mosaicstack/web build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
+17
-43
@@ -197,41 +197,24 @@ conflict must amend one of them explicitly, never fork a third document
|
||||
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
|
||||
- Fully-designed federation in v1 (D3 — roadmap placeholder only).
|
||||
|
||||
### D15 — Tiered containerized deployment (2026-08-30, containerization lane)
|
||||
### 12. Decision registry
|
||||
|
||||
The stack ships a tiered deployment target, additive to the architecture
|
||||
gate (D8): (1) Standalone tier — docker compose is the canonical
|
||||
single-host deployment: postgres, valkey, openbao, gateway, appservice
|
||||
and the served webUI in one composition, with migrations, health checks,
|
||||
and a documented install/upgrade path; the registry (CI-published
|
||||
images) is the only deployment source. (2) Enterprise tier — Kubernetes
|
||||
manifests for the same service set, phase-gated on the standalone tier
|
||||
holding its acceptance bar. The v1 acceptance bar for the standalone
|
||||
tier: compose-up healthy; webUI hosts agent chat; an in-stack agent can
|
||||
open a PR to this repo; CI validates it; the running deployment adopts
|
||||
the merged change (pull + restart). Federation (D3 clause) remains
|
||||
deferred and unforeclosed. Implementation plan:
|
||||
docs/plans/2026-08-30_containerization.md.
|
||||
|
||||
## 12. Decision registry
|
||||
|
||||
| ID | Decision (short form) |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
||||
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
||||
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
||||
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
||||
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
||||
| D8 | webUI sits over official framework tooling; CLI primary |
|
||||
| D9 | Not a hosted business; company = organizational separation for one operator |
|
||||
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
||||
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
||||
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
||||
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
||||
| D15 | Tiered containerized deployment: compose standalone tier (five-point v1 bar) + phase-gated k8s enterprise tier; registry-only image source | 2026-08-30 containerization lane; plan docs/plans/2026-08-30_containerization.md |
|
||||
| ID | Decision (short form) |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
||||
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
||||
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
||||
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
||||
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
||||
| D8 | webUI sits over official framework tooling; CLI primary |
|
||||
| D9 | Not a hosted business; company = organizational separation for one operator |
|
||||
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
||||
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
||||
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
||||
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
||||
|
||||
The full decision texts are recorded in the operator decision log (USC estate
|
||||
brain, webui-audit lane, `GRILL.md`).
|
||||
@@ -899,12 +882,3 @@ Objective: for alpha 0.0.50, the release cannot publish, report, or display work
|
||||
### Out of scope
|
||||
|
||||
The canonical dispatcher/control-plane vertical slice (work graph, execution attempts, fenced leases, typed check-in, independent verifier dispatch) is decided post-alpha (SDLC-D-033, option B). Multi-pipeline verification certificates (SDLC-D-034 option B) are post-alpha. Full AF-1..AF-4 objective matrices and Mission Control portfolio surfaces are post-alpha.
|
||||
|
||||
## Official CLI Capability and Tool Migration Workstream (T78)
|
||||
|
||||
Normative contract on integration trunk `next`:
|
||||
[docs/requirements/cli-capability-migration.md](./requirements/cli-capability-migration.md):
|
||||
migrates agent-facing operations from directly invoked scripts into documented, first-class
|
||||
`mosaic` CLI command groups, together with the central-registry resolver, capability catalog,
|
||||
adapter boundary, and phased legacy-tool-tree decommission the migration requires. The contract
|
||||
carries its own implementation hold and delivery stages.
|
||||
|
||||
+1
-3
@@ -12,9 +12,7 @@ design; scoping one requires its own PRD section or requirements doc plus
|
||||
review.
|
||||
|
||||
Phases are product phases. The in-flight platform workstreams (KBN-100/101
|
||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, T78 CLI
|
||||
capability migration
|
||||
([requirements](./requirements/cli-capability-migration.md)), and the other
|
||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
|
||||
Part II contracts in the PRD) run as parallel tracks under their own issues
|
||||
and are prerequisites where noted.
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
- [Active task rollup](TASKS.md) — orchestrator-owned work state; workers do not modify it.
|
||||
- [MVP mission manifest](MISSION-MANIFEST.md) — control-plane mission rollup; activity and status remain under its authorized owner.
|
||||
- [Documentation catalog and truth audit](reports/documentation/2026-08-10-docs-catalog-audit.md) — complete baseline inventory, evidence labels, broken-link clusters, and migration recommendations.
|
||||
- [CLI capability migration requirements](requirements/cli-capability-migration.md): T78 official CLI capability and tool migration contract, normative contract with implementation hold (M0).
|
||||
|
||||
## Protected current authority and executable books
|
||||
|
||||
|
||||
@@ -212,20 +212,6 @@ Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts
|
||||
|
||||
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
|
||||
|
||||
### E2E Gate (#1445, P6)
|
||||
|
||||
Trunk publish pipelines run a headless Playwright suite (`e2e` step) before any image publishes: the built gateway `dist` boots on a throwaway embedded PGlite database (isolated via a fresh `HOME`), serves the built SPA bundle through `WEB_DIST_DIR` — the same serving path the gateway image ships — and the suite runs against it inside the pinned `mcr.microsoft.com/playwright` image. `E2E_REQUIRE_SEEDED_AUTH=1` makes login failures hard failures (the skip-when-login-fails guards are a live-environment affordance only). Both image build steps depend on this gate.
|
||||
|
||||
Reproduce locally (Ubuntu-based environments; Fedora's headless-shell rendering is broken):
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)" GATEWAY_PORT=14242 \
|
||||
WEB_DIST_DIR="$PWD/apps/web/dist" HOME="$(mktemp -d)" node apps/gateway/dist/main.js &
|
||||
E2E_REQUIRE_SEEDED_AUTH=1 PLAYWRIGHT_BASE_URL=http://localhost:14242 \
|
||||
pnpm --filter @mosaicstack/web exec playwright test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding New Agent Tools
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
|
||||
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`P0-MAP-CURRENCY-2026-08-29.md`](./P0-MAP-CURRENCY-2026-08-29.md) | REQ-MIG-001 lane-opening verification: SHARED-CONTRACT §5 field map re-verified byte-identical at `next` @ `abb0c936`; workspaces/audit-pattern refinements; measured `mission_tasks.status` writer inventory and the pre-expand stop-write work item |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
kind: verification
|
||||
status: active
|
||||
---
|
||||
|
||||
# P0 Field-Map Currency Verification — 2026-08-29
|
||||
|
||||
**Purpose:** REQ-MIG-001 (native-kanban-sot.md §5) accepts only when "P0 publishes
|
||||
the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract
|
||||
map before any schema lane starts." That map exists: [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md)
|
||||
§5, inspected at `packages/db/src/schema.ts` @ `e72388b2cbfe400842fe940fa6cabf984ed43711`
|
||||
(2026-07-13). The M4-3 schema lane (expand migration 0021+) now opens against the
|
||||
integration trunk `next`. This document re-verifies the map's currency at the
|
||||
lane-opening head and records the measured pre-expand writer inventory. It amends
|
||||
nothing normative in SHARED-CONTRACT.md; where the two disagree, SHARED-CONTRACT.md
|
||||
wins.
|
||||
|
||||
## 1. Currency verification (measured)
|
||||
|
||||
- Map pin: `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13, `main`).
|
||||
- Lane-opening head: `abb0c936011c7f6b8c0bcc90a20a865d5e8a40e9` (`origin/next`,
|
||||
2026-08-29).
|
||||
- Measurement: `git diff e72388b2 abb0c936 -- packages/db/src/schema.ts` reports
|
||||
**300 insertions, 0 deletions** — no existing declaration changed.
|
||||
- The additions: the new declarations `logicalAgentConnectorLeases`,
|
||||
`connectorLeaseAuditLog`, and the hierarchy layer (`companies`, `estates`,
|
||||
`platformProjects`, `workspaces`, `hierarchyGrants`, `hierarchyAuditEvents`,
|
||||
`hierarchyOutbox`, plus their enums and constant arrays); a nullable `issuer`
|
||||
column on the unmapped BetterAuth `accounts` table (shipped as
|
||||
`drizzle/0017_accounts_issuer.sql`); and expanded `drizzle-orm` imports
|
||||
(`sql`, `AnyPgColumn`, `unique`, `check`, `bigint`). None touch a mapped
|
||||
source.
|
||||
- Stronger literal fact: REQ-MIG-001's acceptance names `origin/main`. Measured
|
||||
pin → `origin/main` (`7102ccb9`, 2026-08-13): **63 insertions, 0 deletions**
|
||||
for `schema.ts`, and `origin/main` is an ancestor of `abb0c936`. The map is
|
||||
therefore current at `origin/main` itself, and at the trunk head beyond it.
|
||||
|
||||
**Consequence:** every source column mapped in SHARED-CONTRACT.md §5.4 —
|
||||
`teams`/`team_members`, `projects`, `missions`, `tasks`, `mission_tasks`,
|
||||
`agents`, fleet `backlog` — is byte-identical to the declaration the map
|
||||
inspected. The field map is current as written. No row changes.
|
||||
|
||||
## 2. Refinements available since the pin (context, not map changes)
|
||||
|
||||
1. **The `workspaces` table exists.** The map predates contract 1's hierarchy
|
||||
layer; its "bootstrap workspace" backfill step now has a shipped target:
|
||||
`workspaces` (uuid PK, chained under platform projects per
|
||||
`docs/requirements/hierarchy-schema.md`; hierarchy core in
|
||||
`drizzle/0018_clean_cobalt_man.sql`, audit/outbox in
|
||||
`0019_volatile_killraven.sql`, visibility in
|
||||
`0020_special_betty_brant.sql`). New `workspace_id` columns FK there.
|
||||
2. **The audit/outbox envelope pattern is shipped.** `hierarchyAuditEvents` +
|
||||
`hierarchyOutbox` implement same-transaction semantic event + outbox. The
|
||||
task lane's `task_events`/`task_outbox` mirror the pattern but are
|
||||
workspace-scoped with the composite `(workspace_id, id)` key required by
|
||||
§5.3 and REQ-SOT-004. The hierarchy tables are a pattern reference, never a
|
||||
shared store for task events.
|
||||
3. **Trunk designation.** The integration trunk is `next` (`.mosaic/repo.json`).
|
||||
§1 measures currency at both the literal `origin/main` REQ-MIG-001 names and
|
||||
the trunk head pinned above, so no reinterpretation of the acceptance text
|
||||
is needed.
|
||||
4. **Migration ownership.** SHARED-CONTRACT.md §6 assigns schema/migration
|
||||
ownership to the mission seat `coder2`. Seat identity is operational fleet
|
||||
state, not resolvable from this repository, and is outside this document's
|
||||
scope. The invariant §6 protects binds regardless of seat and is restated
|
||||
here as binding on the M4-3 schema lane: exactly one lane generates
|
||||
migrations at a time; expand is additive; no drop/rename/narrow; constraints
|
||||
validate before NOT NULL.
|
||||
|
||||
## 3. Pre-expand writer inventory (measured 2026-08-29 at `abb0c936`)
|
||||
|
||||
SHARED-CONTRACT.md §5.1 phase 1 requires an N-1 patch that stops
|
||||
`mission_tasks.status` as a write source, plus a writer inventory, before any
|
||||
expand DDL.
|
||||
|
||||
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
|
||||
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
|
||||
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
|
||||
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
|
||||
The same module also exposes `remove`/`removeByMission` DELETE paths —
|
||||
immaterial to `status` writes, listed for inventory completeness.
|
||||
- **Storage-layer surfaces that touch the column without authoring it**
|
||||
(added 2026-08-29 after independent review of the phase-1 patch):
|
||||
`packages/storage/src/migrate-tier.ts` copies whole `mission_tasks` rows
|
||||
between storage tiers and must preserve the stored `status` verbatim — row
|
||||
transport, exempt from the write prohibition (stripping there would corrupt
|
||||
data inside the N-1 window). The generic table-keyed storage adapters
|
||||
(`adapters/postgres.ts`, `adapters/pglite.ts`) register `mission_tasks` in
|
||||
their table maps but have no caller that targets it: measured at this head,
|
||||
every runtime adapter caller passes a fixed collection constant
|
||||
(preferences/insights). Neither surface authors a new `status` value.
|
||||
- **Read-only consumers of `mission_tasks`:** federation verb services
|
||||
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
|
||||
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
|
||||
`brain_update_task` write the separately mapped `tasks` table, a legitimate
|
||||
N-1 writer through the compatibility window.
|
||||
- The ratified contract 5 decision
|
||||
(`docs/requirements/tool-gateway-mapping.md` §3.2, ruled 2026-08-27) freezes
|
||||
the legacy endpoints — including MCP `brain_*` task mutations — for new
|
||||
consumers, while existing consumers keep working until each surface's owning
|
||||
contract retires it. It does not stop existing writes.
|
||||
|
||||
**Standing work item:** the phase-1 stop-write patch (reject or ignore `status`
|
||||
on `mission_tasks` create/update) MUST land before the expand DDL of migration
|
||||
lane M4-3a. It is N-1-safe per the §5.4 row for `mission_tasks.status` (linked
|
||||
status is ignored; the column stays declared and readable through the whole
|
||||
N-1 window; retirement only after no readers).
|
||||
|
||||
## 4. Lane opening
|
||||
|
||||
With this verification merged, REQ-MIG-001's P0-map precondition is satisfied
|
||||
for the M4-3 schema lane at pinned head `abb0c936`. The ordered phases (§5.1),
|
||||
mission candidate-key DDL order (§5.2), audit/proposal DDL order (§5.3), field
|
||||
map (§5.4), and required migration tests (§5.5) bind as written. External
|
||||
import machinery (jarvis-brain/Vikunja shadow import, REQ-MIG-001) and client
|
||||
cutover (REQ-MIG-002) remain out of scope for M4-3; the legacy surface stays
|
||||
frozen for new consumers meanwhile (`tool-gateway-mapping.md` §3.2 decision).
|
||||
@@ -1,315 +0,0 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
audience: developer
|
||||
---
|
||||
|
||||
# Agent Enrollment Command Family — v1 Design (M4-4-0)
|
||||
|
||||
Status: design note (implementation-facing; amends no contract).
|
||||
Authority chain: tool-gateway-mapping.md §3.1 rank-4 row + §4 envelope
|
||||
(ruled 2026-08-27), onboarding-wizard.md §3.5 (D11 minimal enrollment),
|
||||
custody-schema.md §5.2 at revision 13 (agent-grantee FK bound to the
|
||||
live `agents` table — a binding introduced at rev 4 and standing
|
||||
verbatim), PRD §9 D11. Where this note and a ratified contract disagree,
|
||||
the contract wins.
|
||||
|
||||
## 1. What the contracts bind (and what they leave open)
|
||||
|
||||
There is no standalone enrollment contract. The rank-4 family is defined
|
||||
by composition:
|
||||
|
||||
1. **Contract 5 §3.1 rank 4:** "Enroll one agent: harness, credential
|
||||
reference/API-key intake (values never echoed), name/persona,
|
||||
assignment scope (contract 3 §3.5)."
|
||||
2. **Contract 5 §4 — all five sub-clauses:** §4.1 typed request/result
|
||||
DTOs validated at the Gateway boundary (expected-version only where
|
||||
an owning contract defines one); §4.2 closed per-family error enum
|
||||
(validation, authentication, authorization, not-found, conflict,
|
||||
precondition, internal) with HTTP mappings; §4.3 audit linkage — the
|
||||
envelope contributes correlation: every request accepts/generates a
|
||||
correlation id, carried into the audit events **and returned in the
|
||||
result**, with no second audit stream; §4.4 fail-closed — an
|
||||
operation that cannot evaluate its authorization or reach its owning
|
||||
tool refuses, never degrading to a fallback read or direct data
|
||||
access; §4.5 CLI parity — the family MUST be invocable through the
|
||||
official CLI against the same Gateway commands with the same
|
||||
request/result/error contracts (a Gateway command without CLI
|
||||
exposure is a tracked conformance gap).
|
||||
**Idempotency keys are NOT contract 5 §4.3:** the idempotency-key
|
||||
envelope is contract 3 §4.3, ratified as a drafting addition to
|
||||
contract 5 §4's command envelope via contract 3 §7 item 4. Its fence
|
||||
and replay rules bind as written there; §3.1 rule 5 below designs to
|
||||
them.
|
||||
3. **Contract 3 §3.5:** the wizard's enrollment step is minimal (one
|
||||
harness, API-key login, agent name and persona — D11), uses ONLY this
|
||||
family, and is skippable. Wizard witness §6.10: a run that skips the
|
||||
step produces zero enrollment-family mutations.
|
||||
4. **Custody-schema §5.2 (rev 13; binding introduced at rev 4):**
|
||||
contract 7's agent-grantee FK references the live `agents` table
|
||||
(`agents.id`, uuid); an enrollment surface with its own table would
|
||||
force a contract-7 amendment.
|
||||
|
||||
**Assignment scope (open point, pinned here):** the rank-4 row cites
|
||||
contract 3 §3.5, which defines no assignment semantics; the PRD's full
|
||||
enrollment vision (Part I, Standalone flow) includes "account
|
||||
assignment", but the D11 v1 slice is exactly "one harness, API key,
|
||||
name/persona". v1 therefore scopes assignment to the two bindings the
|
||||
minimal slice already implies — the enrolling user becomes the agent's
|
||||
owner (`agents.owner_id`), and the credential reference names which of
|
||||
that user's stored provider credentials the agent uses. Richer
|
||||
assignment (multi-account, comms auto-enroll, workspace placement) is
|
||||
deferred with the rest of the PRD's full flow (D11); when a contract
|
||||
defines it, this family extends by ordinary amendment of the design.
|
||||
The deferral rests on contract 3 §3.5's explicit delegation of
|
||||
enrollment specifics to this family — not on reading the D11 list as
|
||||
exhaustive (it is not: the §3.1 `model`/`provider` fields are required
|
||||
by the live table's NOT NULL columns, though D11 does not name them).
|
||||
|
||||
## 2. Current state (measured 2026-08-29 at `origin/next` = `94d626df`)
|
||||
|
||||
- `agents` table (packages/db `schema.ts`): id uuid PK, name, provider,
|
||||
model, status enum, project_id (legacy `projects`, ON DELETE SET
|
||||
NULL), owner_id → users, system_prompt, allowed_tools, skills,
|
||||
is_system, config jsonb, timestamps. No harness column (provider and
|
||||
model describe the LLM backend, not the harness), no audit coupling.
|
||||
- Sole write path: `packages/brain/src/agents.ts` repository (the only
|
||||
module issuing `insert(agents)`), with three write consumers: the
|
||||
legacy `/api/agents` CRUD controller
|
||||
(`apps/gateway/src/agent/agent-configs.controller.ts`), the `/agent
|
||||
new` chat command (`apps/gateway/src/commands/command-executor.service.ts`
|
||||
→ `brain.agents.create`), and workspace bootstrap
|
||||
(`apps/gateway/src/workspace/project-bootstrap.service.ts`). All
|
||||
three keep serving existing consumers; none is touched by M4-4.
|
||||
- Sealed credential store exists: `ProviderCredentialsService`
|
||||
(apps/gateway/src/agent/) — one row per (userId, provider), values
|
||||
sealed at rest, decrypt server-side only, summaries never carry
|
||||
values.
|
||||
- Harness registry exists (`apps/gateway/src/harness/`), the validation
|
||||
source for the harness field.
|
||||
- Implementation pattern: the merged hierarchy module (M4-1) —
|
||||
transaction-scoped command context, in-tx authorization, discriminated
|
||||
result unions, same-transaction semantic audit event + transactional
|
||||
outbox, no-oracle not_found folding.
|
||||
|
||||
**F1 — contract-5 mapping note (disposition, not an amendment):**
|
||||
`/api/agents` appears nowhere in contract 5 — neither as a P0 row nor in
|
||||
the §3.2 legacy non-substitutes list (the ruled §3.2 freeze names
|
||||
specific endpoints, and `/api/agents` is not among them). The operative
|
||||
constraints are §3.3's amendment-only rule for new mapping rows and §5's
|
||||
closure rule: this design adds no new consumer to `/api/agents` and
|
||||
builds the rank-4 family as the P1 path for enrollment. Adding the
|
||||
missing P0 row is a contract amendment for a future S2 pass; nothing in
|
||||
M4-4 depends on it.
|
||||
|
||||
## 3. Command family surface (v1)
|
||||
|
||||
One command, one query. Module: `apps/gateway/src/enrollment/`
|
||||
(`enrollment.module.ts`), mirroring the hierarchy module's shape.
|
||||
|
||||
### 3.1 `agent.enroll` (mutation)
|
||||
|
||||
Request DTO (shared types package, class-validator at the boundary):
|
||||
|
||||
| Field | Type | Rule |
|
||||
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `harness` | string | syntactically invalid (empty/malformed) → `validation_failed`; well-formed but not in the harness registry → `precondition_failed` |
|
||||
| `correlationId` | string (uuid) | optional; generated when absent (contract 5 §4.3); carried into audit events and returned in the result |
|
||||
| `replayMode` | 'actor-bound' | optional, default `actor-bound`. `shared` is seed-only (contract 3 §4.3 binds it to the §3.4 canonical seed key set and "no other operation can carry a shared declaration"; §7 item 4 closes it); a `shared` declaration here is refused `validation_failed`, executes nothing, and records no fence row |
|
||||
| `name` | string | non-empty, trimmed, ≤ 200 chars |
|
||||
| `persona` | string \| null | optional; stored as the agent's system prompt |
|
||||
| `model` | string | non-empty (provider-qualified model id) |
|
||||
| `provider` | string | non-empty; names the credential's provider |
|
||||
| `credential` | discriminated union | `{ mode: 'reference' }` — a credential for (actor, provider) MUST already exist; `{ mode: 'intake', type: 'api_key', value: string }` — value is sealed into the credential store in the same flow |
|
||||
| `idempotencyKey` | string (uuid) | required (contract 3 §4.3, ratified into contract 5 §4 via contract 3 §7 item 4) |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **Never echoed.** The credential value appears in no result DTO, no
|
||||
audit event, no outbox payload, and no log line. The result carries
|
||||
only `{ provider, credentialMode }`.
|
||||
2. **Intake = the existing sealed store, inside the transaction.**
|
||||
`intake` writes through the sealed-store path
|
||||
(`ProviderCredentialsService.store` semantics: seal-at-rest, upsert
|
||||
per (userId, provider)) **in the same transaction** as the agent
|
||||
insert — a failure after the credential write rolls everything back,
|
||||
leaving no orphan credential. Enrollment persists no second copy and
|
||||
no plaintext.
|
||||
3. **Reference must resolve.** `reference` with no stored credential for
|
||||
(actor, provider) refuses with `precondition_failed` (nothing is
|
||||
created).
|
||||
4. **Ownership.** `owner_id` = the authenticated actor. v1 authorization
|
||||
is AuthGuard-authenticated user; no hierarchy grant is required
|
||||
because v1 enrollment binds no hierarchy node (§1 assignment-scope
|
||||
pin). `is_system` is never settable through this command.
|
||||
5. **Idempotency fence (contract 3 §4.3, in full).** The command layer
|
||||
records, in a uniqueness-constrained fence table in the same
|
||||
transaction as the mutation and its audit event: the key, the
|
||||
operation identifier (`agent.enroll`), the acting principal, the
|
||||
authorization scope, a digest of the canonicalized request payload
|
||||
(the digest input EXCLUDES the credential value — it covers
|
||||
provider + credentialMode, never plaintext), the declared replay
|
||||
mode (always `actor-bound` for this family — the `shared` refusal
|
||||
in the table above means no shared fence row can exist here; the
|
||||
column is kept for envelope-shape fidelity and mode-mismatch
|
||||
collision checks), and a reference to the committed outcome (the
|
||||
agent id). The recorded **authorization scope** for this family is
|
||||
pinned to the acting principal's platform-user scope (v1
|
||||
authorization is grant-free per rule 4, so the scope is the
|
||||
authenticated-user identity domain — recorded so the §4.3
|
||||
scope-equality check has a defined value). Fence uniqueness is the
|
||||
pair (operation identifier, key). **Replay:** a submission whose
|
||||
(operation, key) is recorded is first authorized exactly as a fresh
|
||||
submission; then replay-mode, scope, and digest equality are
|
||||
checked (a mismatch on any — including scope — is a collision);
|
||||
then **target-result authorization** — the submitter must hold, at
|
||||
replay time, read authority on the referenced agent row under
|
||||
§3.2's rule (owner or admin) — plus recorded-actor equality
|
||||
(`actor-bound`). A passing replay executes nothing, returns the
|
||||
recorded outcome, and appends a replay access event (non-mutation
|
||||
audit class: accessing principal, current correlation id,
|
||||
fence-row reference). Any equality or authorization failure refuses
|
||||
with the single bounded `conflict` shape — constant, identifying no
|
||||
record — preserving the no-existence-oracle rule. **Concurrency
|
||||
(contract 3 §4.3's rule, ratified via §7 item 4):** two submissions
|
||||
with the same (operation, key) serialize on the fence's unique
|
||||
constraint — exactly one executes; the loser waits for the winner's
|
||||
transaction, and is then handled as a replay if it committed
|
||||
(through the full replay path above) or executes afresh if it
|
||||
aborted. A unique-violation race never surfaces as an unhandled
|
||||
internal fault.
|
||||
6. **Audit + outbox, same transaction.** Insert into `agents` +
|
||||
sealed credential write (intake mode) + fence row + semantic audit
|
||||
event (`agent.enrolled`: actor, agent id, harness, provider, name,
|
||||
credentialMode — no credential material) + outbox row commit
|
||||
atomically, hierarchy-pattern style. Audit rows reference the agent
|
||||
by **snapshot id, not FK** — mirroring the hierarchy audit tables'
|
||||
deliberate FK-free linkage so audit history survives agent deletion
|
||||
through the legacy CRUD DELETE path.
|
||||
|
||||
Result union: `enrolled { agent, correlationId }` | refusal from the
|
||||
§3.3 enum (refusals also carry the correlation id, per contract 5
|
||||
§4.3's end-to-end traceability). `agent` in the result is the persisted
|
||||
row minus nothing sensitive (the table stores no credential material).
|
||||
|
||||
### 3.2 `agent.enrollment.get` (query)
|
||||
|
||||
By agent id; actor must be the owner (or admin). Unauthorized and
|
||||
missing fold to the same `not_found` wire shape (contract 2
|
||||
no-existence-oracle rule, applied family-wide for uniformity).
|
||||
|
||||
The query carries the same non-state envelope as the mutation
|
||||
(contract 5 §4.3; contract 3's envelope reconciliation confirms closed
|
||||
query responses carry it): typed request DTO with an optional
|
||||
`correlationId` (generated when absent) and a typed result —
|
||||
`found { agent, correlationId }` | `not_found` (the folded shape,
|
||||
also carrying the correlation id). Queries take no idempotency key
|
||||
(the fence binds mutations).
|
||||
|
||||
### 3.3 Error enum (closed, §4.2)
|
||||
|
||||
`validation_failed` 400 · `authentication_failed` 401 ·
|
||||
`authorization_refused` 403 (owner-only paths; folded to `not_found`
|
||||
where §3.2 applies) · `not_found` 404 · `conflict` 409 (the single
|
||||
bounded idempotency refusal shape of §3.1 rule 5) · `precondition_failed`
|
||||
422 (unresolvable credential reference; well-formed harness not in the
|
||||
registry — syntactic invalidity is `validation_failed` per the §3.1
|
||||
table) · `internal_fault` 500 (also the §4.4 fail-closed class when the
|
||||
owning tool is unreachable; unauthorized-fallback behavior is
|
||||
prohibited).
|
||||
|
||||
## 4. Schema delta (migration 0021, additive-only)
|
||||
|
||||
Extend `agents` — no new agent table, preserving custody-schema §5.2's
|
||||
FK binding without amendment:
|
||||
|
||||
- `harness` text NULL — registered harness name; NULL for pre-existing
|
||||
rows (legacy rows predate the concept).
|
||||
- `enrolled_at` timestamptz NULL — set by `agent.enroll`; NULL marks a
|
||||
legacy (non-enrolled) row. No backfill: enrollment is a fact this
|
||||
command creates, not one to invent for existing rows.
|
||||
|
||||
New tables, mirroring the hierarchy audit/outbox pair (pattern reuse,
|
||||
separate store): `agent_audit_events` (append-only: id, event_type,
|
||||
actor id, agent id — snapshot value, no FK, per §3.1 rule 6 —
|
||||
correlation id, causation id, payload jsonb, created_at; per-agent
|
||||
ordering index), `agent_outbox` (hierarchy-outbox shape), and
|
||||
`agent_idempotency_fence` (contract 3 §4.3 shape: operation identifier,
|
||||
key, acting principal, authorization scope, canonicalized-payload
|
||||
digest, replay mode, committed-outcome reference (agent id), created_at;
|
||||
UNIQUE (operation identifier, key)). Persona reuses the existing
|
||||
`system_prompt` column; no version column (no ratified expected-version
|
||||
rule names `agents` — §4.1 binds only where the owning contract defines
|
||||
one).
|
||||
|
||||
Witnesses (real PostgreSQL, lane standard): append-only enforcement,
|
||||
same-tx atomicity (agent row + credential write + fence row + audit +
|
||||
outbox all-or-nothing under injected failure at multiple points,
|
||||
including after the credential write), fence uniqueness on
|
||||
(operation, key).
|
||||
|
||||
Sequencing: additive DDL via the same migration path as 0018–0020
|
||||
(hierarchy). The docs/native-kanban-sot/SHARED-CONTRACT.md §5.3 DDL
|
||||
gate binds the kanban lane's audit/proposal DDL, not this lane; if a
|
||||
pending operator ruling on migration sequencing changes mechanics
|
||||
lane-wide, re-check before generating 0021.
|
||||
|
||||
## 5. Witnesses the implementation slice must ship
|
||||
|
||||
1. Never-echo: enroll via `intake`, assert the value string is absent
|
||||
from the HTTP result, the audit row, the outbox payload, and captured
|
||||
logs.
|
||||
2. Sealed-store single-copy: after intake, the credential exists only in
|
||||
`provider_credentials` (sealed), and `agents` has no credential
|
||||
column at all.
|
||||
3. Reference-resolution refusal (`precondition_failed`, no row created).
|
||||
4. Harness refusals, both codes: syntactically invalid →
|
||||
`validation_failed`; well-formed registry miss →
|
||||
`precondition_failed` (against the live registry).
|
||||
5. Idempotency (contract 3 §4.3 set): actor-bound replay returns the
|
||||
recorded outcome and executes nothing (no new agent/audit/outbox
|
||||
mutation rows; a replay access event is appended); payload-digest
|
||||
mismatch, replay-mode mismatch, scope mismatch, and different-actor
|
||||
actor-bound replay each refuse with the single bounded `conflict`
|
||||
shape; a replay is re-authorized fresh (a submitter whose
|
||||
authorization was revoked since the original is refused, not
|
||||
replayed); a `shared` declaration on `agent.enroll` is refused
|
||||
`validation_failed` with nothing executed and no fence row
|
||||
recorded (seed-only rule); two concurrent same-(operation, key)
|
||||
submissions produce exactly one mutation, the loser resolving
|
||||
through the replay path (no unhandled unique-violation fault).
|
||||
6. Same-tx atomicity fault injection (agent / credential write / fence
|
||||
/ audit / outbox), including a failure injected after the intake
|
||||
credential write commits its statement — everything rolls back, no
|
||||
orphan credential.
|
||||
7. Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
|
||||
call → zero rows in `agents`/`agent_audit_events`/`agent_outbox`/
|
||||
`agent_idempotency_fence` attributable to the family.
|
||||
8. `is_system` injection attempt is rejected by DTO validation.
|
||||
9. Correlation-id witness (contract 5 §6.3): a correlation id submitted
|
||||
on `agent.enroll` appears in its audit event(s) and in the result;
|
||||
the same holds for `agent.enrollment.get`'s result; the §6.3 static
|
||||
companions (no `any`-typed boundary pass-through; single audit
|
||||
emitter) apply. §6.3's no-existence-oracle probe: an unauthorized
|
||||
`agent.enrollment.get` of an existing agent and a get of a
|
||||
nonexistent id return indistinguishable results.
|
||||
10. CLI-parity witness (contract 5 §6.4): a CLI smoke invocation of
|
||||
`agent.enroll` and `agent.enrollment.get` against the Gateway
|
||||
succeeds with the same typed results the web client receives. The
|
||||
implementation slice therefore SHIPS CLI exposure for both
|
||||
operations (contract 5 §4.5 — a Gateway command without CLI
|
||||
exposure is a tracked conformance gap; this design refuses to open
|
||||
one).
|
||||
11. Fail-closed witness (contract 5 §6.5): with the owning tool or
|
||||
grant state unreachable (fault injection), the operation returns
|
||||
the internal-fault or authorization-refusal class and performs no
|
||||
fallback read/write.
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
Wizard orchestration (M4-6); any UI (D8/D12); un-enroll/update lifecycle
|
||||
(no contract requires it in v1 — the legacy write surfaces named in §2
|
||||
keep serving existing consumers); OAuth login, multi-account, comms
|
||||
auto-enroll, model recommendation (PRD full flow, deferred by D11);
|
||||
contract amendments (F1 recorded above for a future S2 pass). CLI
|
||||
exposure is explicitly IN scope (witness 10 — contract 5 §4.5 binds it).
|
||||
@@ -1,99 +0,0 @@
|
||||
# Plan — Stack Containerization (tiered deployment)
|
||||
|
||||
Status: DRAFT for review. Charter: fleet/lanes/stack-containerization
|
||||
(brain) NORTH-STAR.md; PRD amendment in the same PR adds D15.
|
||||
Supersedes nothing; sequences the absorbed M4 remainder per its lane.
|
||||
|
||||
## Measured baseline (origin/next @ 143ba0f5, 2026-08-30)
|
||||
|
||||
- `docker-compose.yml`: dev infrastructure only — postgres (pgvector),
|
||||
valkey, otel-collector, jaeger. No application services.
|
||||
- `docker-compose.federated.yml`: standalone overlay for the FEDERATED
|
||||
storage tier (own postgres/valkey; port-conflicts the base stack by
|
||||
design). Not an app deployment.
|
||||
- `docker/gateway.Dockerfile`, `docker/appservice.Dockerfile`:
|
||||
multi-stage production builds (node:22-alpine) EXIST; the gateway image
|
||||
includes the web SPA bundle (#1444).
|
||||
- CI (`publish.yml`) builds and publishes these images (next-channel
|
||||
prereleases + main stable), and runs `verify:release` fail-closed.
|
||||
- Gap: no stack-level composition wires gateway+appservice+data plane
|
||||
into one deployable unit; no blessed install/upgrade path; no
|
||||
in-container agent-runtime story for the dogfood loop.
|
||||
|
||||
## Target (PRD D15 amendment)
|
||||
|
||||
Tiered deployment, additive to the existing architecture:
|
||||
|
||||
1. **Standalone tier (v1 bar)**: `docker compose up` on one host brings
|
||||
postgres, valkey, openbao, gateway, appservice (and the webUI the
|
||||
gateway serves) to healthy; migrations apply; the webUI hosts agent
|
||||
chat; an in-stack agent can read this repo and open a PR; CI
|
||||
validates; the deployment adopts merged images (pull + restart).
|
||||
2. **Enterprise tier (post-v1)**: Kubernetes manifests (or Helm) for the
|
||||
same service set, phase-gated on the standalone bar holding.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — blessed standalone compose
|
||||
|
||||
- A1 Compose service definitions for gateway + appservice joining the
|
||||
existing infra compose (profiles: `dev` keeps today's behavior;
|
||||
`stack` adds the app tier), with health checks and dependency order.
|
||||
- A2 Migrations on boot (or an explicit migrate step) with idempotency
|
||||
and version pinning; init-db.sql folded into pg-init.
|
||||
- A3 Openbao in the compose set (secret plumbing for the app tier).
|
||||
- A4 `.env.example` + `mosaic.config.json` defaults documented for the
|
||||
standalone mode; mode recorded per the mode-conversion contract.
|
||||
- A5 Smoke: `docker compose --profile stack up` green on a scratch host;
|
||||
webUI served; agent chat reachable; failures catalogued and fixed.
|
||||
- Acceptance: the five-point NORTH-STAR bar measured live.
|
||||
|
||||
### Phase B — component completion
|
||||
|
||||
- Interface assumption (velma verdict A1, P5-RM-005/006): in-stack
|
||||
dogfood agents inherit SEAT-GRADE identity — credential-slot
|
||||
isolation, wrapper-first enforcement, no privileged coordination
|
||||
identity, evidence by references that resolve outside the container
|
||||
lifetime.
|
||||
- Decompose JIT from A5's catalogue. Known candidates: agent runtime
|
||||
bits (brain/tool access paths in-container), repo credentials for the
|
||||
dogfood agent, watch/comms surfaces inside the deployment.
|
||||
|
||||
### Phase C — CI/CD parity
|
||||
|
||||
- Publish pipeline is the only image source (already true); add the
|
||||
deployment-side pull/upgrade path (compose pull + migrate + restart =
|
||||
next iteration); document the promotion flow next -> registry ->
|
||||
deployment.
|
||||
|
||||
### Phase D — coordinator integration (GATED)
|
||||
|
||||
- Gate (velma verdict C2): blocked until the checkpoint-and-lease child
|
||||
of the guides-proposed control-plane refactor — core + WU-P1-CHECKPOINT
|
||||
(schema, freshness, incarnation, clean-replacement resume; D57-D60
|
||||
lineage) — carries an independent target-bound PASS. Wiring restarts
|
||||
against the core alone re-creates the stale-incarnation failure class
|
||||
D57-D60 closed. Transitive: inherits the T108 gates (P0 exit + Jason
|
||||
P1 authorization).
|
||||
- Scope (velma verdict C1): lifecycle actions (start/stop/restart/
|
||||
health/recovery) executed by the SHIPPED coord client over the one
|
||||
typed coordination contract (request id, actor identity, epoch,
|
||||
revision, lease, correlation; typed stale rejection; worker role
|
||||
boundary). No second coordination interface gets designed here —
|
||||
containerization consumes the coordination contract, never defines it.
|
||||
|
||||
### Phase E — enterprise tier
|
||||
|
||||
- k8s manifests/Helm for the same set; phase-gated on Phase A holding.
|
||||
|
||||
### Absorbed M4 remainder
|
||||
|
||||
- M4-3 pivot: KBN-101 foundation first (per ruling R6), then expand DDL.
|
||||
- M4-5: lands inside Phase B/C where natural.
|
||||
- M4-6 (composes M4-1+M4-4): last, as designed.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- No Kubernetes in v1; no multi-host federation; no replacement of the
|
||||
fleet's brain-based seats (the stack is an additional operator
|
||||
surface); no on-host image builds for deployment (registry only).
|
||||
@@ -13,10 +13,6 @@ status: active
|
||||
- [Documentation structure README implementation](2026-08-10-docs-structure-readme.md) — completed implementation plan for the documentation contract and atlas.
|
||||
- [Documentation catalog and truth audit](2026-08-10-docs-catalog-audit.md) — audit method, evidence statuses, deliverables, and acceptance criteria.
|
||||
|
||||
## Feature design plans
|
||||
|
||||
- [Agent enrollment command design](2026-08-29-agent-enrollment-command-design.md) — v1 rank-4 enrollment command family: contract composition, command surface, schema delta, witnesses (M4-4-0).
|
||||
|
||||
After a plan is delivered, update the canonical guide, contract, decision, or index. Do not cite a plan as proof that intended behavior shipped.
|
||||
|
||||
## Related
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,748 +0,0 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
source_of_truth: true
|
||||
---
|
||||
|
||||
# Official Mosaic CLI Capability and Tool Migration
|
||||
|
||||
- **Workstream:** T78
|
||||
- **Status:** active requirements contract, implementation held by the M0 gates
|
||||
- **Decision authority:** Jason Woltje
|
||||
- **Design owner:** Vision
|
||||
- **Integration trunk:** `next`
|
||||
|
||||
This contract is authoritative only on the integration trunk `next`. Branch copies are proposals.
|
||||
Publication does not authorize implementation until the M0 milestone, task-graph, interface, and
|
||||
partition gates pass.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Migrate agent-facing operations from directly invoked scripts into documented, first-class command
|
||||
groups in the existing TypeScript and Node.js `mosaic` CLI. The CLI becomes the stable interface
|
||||
for operators, agents, the webUI, future seat containers, and future `mosaicd` execution.
|
||||
|
||||
The mission also phases out the installed `~/.config/mosaic/tools` script surface. Existing scripts
|
||||
may remain private compatibility adapters only while measured consumers still require them.
|
||||
|
||||
## 2. Product alignment
|
||||
|
||||
Items 1 through 3 implement PRD D8 and D12:
|
||||
|
||||
1. The CLI is the primary execution surface.
|
||||
2. The webUI uses Gateway APIs backed by the same official capability contracts.
|
||||
3. A missing official capability is built before a webUI bypass is accepted.
|
||||
|
||||
This contract adds one explicit extension beyond D8 and D12: no harness, skill, or agent receives a
|
||||
separate business-logic path around the CLI and Gateway capability contract.
|
||||
|
||||
This contract does not replace the fleet north star, issue `#1382`, the fleet configuration
|
||||
contract `#758`, the exact fleet communications contract `#766`, or future container and `mosaicd`
|
||||
specifications. It defines the interfaces those tracks consume.
|
||||
|
||||
## 3. Fixed decisions
|
||||
|
||||
| ID | Decision |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| T78-D1 | Extend the existing official TypeScript and Node.js `mosaic` CLI. A second Python or shell entrypoint is forbidden. |
|
||||
| T78-D2 | Expose documented groups such as `mosaic git`, `mosaic comms`, and `mosaic ci`. A generic public `mosaic tools` passthrough is forbidden. |
|
||||
| T78-D3 | Resolve homes, endpoints, sockets, tool locations, and runtime paths through the central registry and one typed resolver. Commands do not hard-code them. |
|
||||
| T78-D4 | One rootless container per seat is the target sandbox. It has a read-only root filesystem, no container-runtime socket, and lifecycle through future `mosaicd`. |
|
||||
| T78-D5 | Dispatch is per-site. Localhost `orch-01` alone dispatches USC-seat implementation. Homelab `orch-01` alone dispatches homelab-seat implementation and homelab-owned surfaces. |
|
||||
| T78-D6 | Tmux and fleet-comms remain temporary communications adapters behind a transport-neutral CLI contract. |
|
||||
| T78-D7 | Decommissioning is phased and mechanically enforced. Removal requires zero measured consumers and a discriminating planted-reference control. |
|
||||
|
||||
Derived security boundary:
|
||||
|
||||
- `~/.mosaic/tools` is canonical working source during migration. It is not automatically trusted
|
||||
runtime installation state.
|
||||
- Reviewed source is promoted into installed or packaged runtime artifacts.
|
||||
- A multi-writer brain-repository push must not silently replace credential-bearing executable code
|
||||
used by every seat.
|
||||
|
||||
## 4. Explicitly rejected alternatives
|
||||
|
||||
| Alternative | Rejection reason |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| Separate Python CLI | Creates a second contract, release path, and policy surface. |
|
||||
| Public `mosaic tools <script>` passthrough | Preserves script names and paths as the API instead of defining capabilities. |
|
||||
| CLI allowlists as the sandbox | Parser allowlists do not isolate files, credentials, processes, networks, or container control. |
|
||||
| Execute the synced working tree as the final runtime | A brain push would become host-wide code execution authority. |
|
||||
| Big-bang script rewrite and deletion | Mature queue, identity, credential, and uncertainty behavior would be changed without parity evidence. |
|
||||
| Tmux-shaped communications API | It would force future Matrix or native transports to preserve tmux concepts. |
|
||||
|
||||
## 5. Terminology
|
||||
|
||||
- **Central registry:** the schema-v1 `config.json` authority filed in issue `#1382`.
|
||||
- **Registry resolver:** the typed reader that validates and resolves central-registry values.
|
||||
- **Capability catalog:** the typed inventory of public capability identifiers and behavior. It is
|
||||
not the central registry.
|
||||
- **Capability policy:** data that maps verified actor and lane identity to allowed capabilities and
|
||||
scopes.
|
||||
- **Local adapter:** a temporary in-process or private-script implementation used before `mosaicd`
|
||||
is available.
|
||||
- **Broker adapter:** the future client transport to `mosaicd` outside the seat container.
|
||||
- **Installed legacy tree:** `~/.config/mosaic/tools`.
|
||||
- **Canonical working source:** `~/.mosaic/tools` during the migration period.
|
||||
- **Runtime artifact:** reviewed package or installed bytes actually executed by a seat.
|
||||
|
||||
## 6. Public CLI grammar
|
||||
|
||||
### CLI-REQ-001: First-class command groups
|
||||
|
||||
The official help surface MUST register domain groups directly:
|
||||
|
||||
```text
|
||||
mosaic git ...
|
||||
mosaic comms ...
|
||||
mosaic ci ...
|
||||
```
|
||||
|
||||
Future domains MAY include `infra`, `identity`, and other reviewed capability families. They MUST
|
||||
NOT appear through a generic script dispatcher.
|
||||
|
||||
### CLI-REQ-002: Stable command shape
|
||||
|
||||
New capability commands use this grammar:
|
||||
|
||||
```text
|
||||
mosaic <domain> <resource> <verb> [target] [options]
|
||||
```
|
||||
|
||||
The first pilot freezes these paths:
|
||||
|
||||
```text
|
||||
mosaic git issue list
|
||||
mosaic git issue view <number>
|
||||
mosaic git issue comment <number> --input <path|->
|
||||
```
|
||||
|
||||
Capability identifiers are independent from display text:
|
||||
|
||||
| Command | Capability ID | Class |
|
||||
| -------------------------- | ------------------- | ---------------- |
|
||||
| `mosaic git issue list` | `git.issue.list` | read |
|
||||
| `mosaic git issue view` | `git.issue.view` | read |
|
||||
| `mosaic git issue comment` | `git.issue.comment` | bounded mutation |
|
||||
|
||||
Renaming a command path does not silently rename its capability identifier. Either change requires a
|
||||
versioned compatibility decision.
|
||||
|
||||
### CLI-REQ-003: Common targeting options
|
||||
|
||||
The pilot supports:
|
||||
|
||||
- `--instance <name>` for the configured provider instance.
|
||||
- `--repo <owner/name>` for the provider repository.
|
||||
- `--format <table|json>` for output selection.
|
||||
- `--correlation-id <id>` for a caller-supplied valid identifier. Omission generates one.
|
||||
- `--idempotency-key <key>` for mutations. Omission generates one and returns it.
|
||||
|
||||
An instance may be inferred only when the registry has exactly one valid instance for that domain.
|
||||
A repository may be inferred only from a validated current repository declaration and an
|
||||
unambiguous canonical remote. Ambiguity fails closed and names the missing field.
|
||||
|
||||
No public option forces local compatibility mode when policy selected broker mode. A caller cannot
|
||||
downgrade the execution boundary.
|
||||
|
||||
### CLI-REQ-004: Mutation input
|
||||
|
||||
`git.issue.comment` reads its body from `--input <path>` or stdin with `--input -`. The CLI MUST:
|
||||
|
||||
1. reject a missing or empty body.
|
||||
2. apply a documented byte limit before provider access.
|
||||
3. never place the body in process arguments, diagnostics, or audit metadata.
|
||||
4. compute a body digest for read-back verification without exposing the body.
|
||||
5. avoid automatic retry after an uncertain provider mutation.
|
||||
|
||||
### CLI-REQ-005: Structured result envelope
|
||||
|
||||
JSON output uses one versioned envelope:
|
||||
|
||||
```ts
|
||||
interface CapabilityResultV1<T> {
|
||||
schemaVersion: 1;
|
||||
capabilityId: string;
|
||||
status: 'succeeded' | 'invalid' | 'denied' | 'failed' | 'uncertain' | 'unavailable';
|
||||
executionMode: 'local-adapter' | 'mosaicd';
|
||||
identityTrust: 'local-asserted' | 'runtime-verified';
|
||||
correlationId: string;
|
||||
idempotencyKey?: string;
|
||||
target: Record<string, string | number | boolean | null>;
|
||||
data?: T;
|
||||
diagnostics: Array<{
|
||||
code: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
retryable: boolean;
|
||||
}>;
|
||||
audit:
|
||||
| { authority: 'mosaicd'; recorded: true; eventId: string }
|
||||
| { authority: 'none'; recorded: false; localEventId?: string };
|
||||
}
|
||||
```
|
||||
|
||||
`target` and `diagnostics` contain no credentials or unbounded provider body. Table output is a
|
||||
human view of the same result and cannot carry a different verdict.
|
||||
|
||||
### CLI-REQ-006: Exit behavior
|
||||
|
||||
| Exit | Meaning |
|
||||
| ---: | --------------------------------------------------------------------------- |
|
||||
| 0 | `succeeded` |
|
||||
| 2 | `invalid`: invalid input, invalid configuration, or unsupported schema |
|
||||
| 3 | `denied` by capability or scope policy |
|
||||
| 4 | `failed` with a confirmed non-success outcome |
|
||||
| 5 | `uncertain`, including a mutation whose provider result cannot be confirmed |
|
||||
| 6 | `unavailable`, including missing broker, credentials, or required adapter |
|
||||
|
||||
A provider HTTP success alone is insufficient. The adapter validates the expected response shape.
|
||||
A mutation that may have landed but lacks confirmation returns exit 5 and is never described as
|
||||
failed or safe to retry. For a provider-native idempotent mutation, manual reconciliation MAY retry
|
||||
the same key. For `uncertain-no-retry`, help directs the caller to a read-back check and forbids
|
||||
mutation retry.
|
||||
|
||||
### CLI-REQ-007: Help and discovery
|
||||
|
||||
The capability catalog generates or validates:
|
||||
|
||||
- `mosaic --help` command-group listing.
|
||||
- group and command help.
|
||||
- stable capability identifiers.
|
||||
- machine-readable capability discovery.
|
||||
- documentation tables.
|
||||
- policy-generation inputs.
|
||||
- tests that reject undocumented public commands and orphaned capabilities.
|
||||
|
||||
## 7. Central registry resolver
|
||||
|
||||
### CFG-REQ-001: One distinct resolver
|
||||
|
||||
Implement one exported resolver named `MosaicRegistryResolver` or another name explicitly approved
|
||||
in the contract review. It MUST NOT be named `ConfigService`. The existing
|
||||
`packages/mosaic/src/config/config-service.ts` exports `ConfigService` for SOUL, USER, and TOOLS
|
||||
content and remains a separate concern.
|
||||
|
||||
### CFG-REQ-002: Frozen schema consumption
|
||||
|
||||
The resolver consumes schema v1 from issue `#1382` without creating parallel keys. Every key is
|
||||
optional. The exact v1 surface is:
|
||||
|
||||
- `$schema`, with the known marker `mosaic-config-v1`.
|
||||
- `mosaicHome`, reserved, null, and without a v1 consumer.
|
||||
- `brainHome`, default `~/.mosaic`.
|
||||
- `instances.gitea.<name>.url`.
|
||||
- `fleet.socket`.
|
||||
- `harnessConfig.pi.agentDir`.
|
||||
- `harnessConfig.claude.configDir`.
|
||||
- `harnessConfig.claude.secureStorageDir`.
|
||||
|
||||
Credential values, model and effort defaults, and `fleet.rosterPath` are forbidden. A non-null
|
||||
`mosaicHome` value fails validation because v1 reserves the field without implementing relocation.
|
||||
An absent or null `$schema` is interpreted as v1, the exact `mosaic-config-v1` marker is accepted,
|
||||
and every other non-null marker fails before value resolution.
|
||||
|
||||
Absent or null values select the framework default. A `~` path prefix expands at read time and is
|
||||
never rewritten into the user file. Unknown top-level and nested keys warn loudly and are ignored
|
||||
for rolling-version compatibility. Every warning and machine-readable diagnostic names the full
|
||||
ignored key path, so a typo is visible at every read.
|
||||
|
||||
Fail-closed read behavior applies to invalid JSON, a failed C1 version check, a known key with an
|
||||
invalid type or value, and a present but empty or invalid override. An optional
|
||||
`mosaic registry validate` lint mode MAY reject unknown keys for operator validation, but the normal
|
||||
resolver read path does not. This top-level group is separate from the existing `mosaic config`
|
||||
commands backed by `ConfigService`.
|
||||
|
||||
### CFG-REQ-003: Resolution precedence
|
||||
|
||||
For each supported value, resolution follows exactly:
|
||||
|
||||
1. the schema-defined `MOSAIC_<KEY>_OVERRIDE` environment override.
|
||||
2. validated `config.json` value.
|
||||
3. one centralized framework default, when the key defines a default.
|
||||
|
||||
A present override always wins. An empty or invalid override fails and does not fall through to the
|
||||
file or default. `$schema` and reserved `mosaicHome` have no environment override. Consumed values
|
||||
use this collision-free mapping:
|
||||
|
||||
| Registry key | Environment override |
|
||||
| --------------------------------------- | ---------------------------------------------------------- |
|
||||
| `brainHome` | `MOSAIC_BRAIN_HOME_OVERRIDE` |
|
||||
| `fleet.socket` | `MOSAIC_FLEET_SOCKET_OVERRIDE` |
|
||||
| `harnessConfig.pi.agentDir` | `MOSAIC_HARNESS_CONFIG_PI_AGENT_DIR_OVERRIDE` |
|
||||
| `harnessConfig.claude.configDir` | `MOSAIC_HARNESS_CONFIG_CLAUDE_CONFIG_DIR_OVERRIDE` |
|
||||
| `harnessConfig.claude.secureStorageDir` | `MOSAIC_HARNESS_CONFIG_CLAUDE_SECURE_STORAGE_DIR_OVERRIDE` |
|
||||
| `instances.gitea.<name>.url` | `MOSAIC_INSTANCES_GITEA_<NAME>_URL_OVERRIDE` |
|
||||
|
||||
Gitea instance names match `[a-z][a-z0-9-]*`. The override name uppercases the instance name and
|
||||
maps hyphen to underscore. Underscores are not valid in source instance names, so two valid names
|
||||
cannot flatten to the same override.
|
||||
|
||||
A value without a valid result fails before adapter or provider access. Invalid known URLs, socket
|
||||
names, paths, and value types fail closed. Unknown keys follow CFG-REQ-002.
|
||||
|
||||
### CFG-REQ-004: Typed provenance
|
||||
|
||||
Every resolved value carries non-secret provenance:
|
||||
|
||||
```ts
|
||||
type RegistryValueSource = 'override' | 'registry' | 'framework-default';
|
||||
|
||||
interface ResolvedRegistryValue<T> {
|
||||
key: string;
|
||||
value: T;
|
||||
source: RegistryValueSource;
|
||||
schemaVersion: 1;
|
||||
}
|
||||
```
|
||||
|
||||
Machine-readable diagnostics include the full path of every ignored unknown key. Diagnostics may
|
||||
name a known key and source class. They do not emit credential values or unrelated configuration.
|
||||
|
||||
### CFG-REQ-005: Bootstrap and path safety
|
||||
|
||||
Registry discovery is the fixed path `~/.config/mosaic/config.json`. It has no v1 search path and no
|
||||
alternate location. The file is the one user-updatable path inside `~/.config/mosaic` and is
|
||||
protected by a deny-wins upgrade carve-out. Upgrades never overwrite user edits.
|
||||
|
||||
This fixed bootstrap avoids circular dependence on reserved `mosaicHome`. A seat container reads its
|
||||
own internal `~/.config/mosaic/config.json`, supplied by the container topology, rather than a host
|
||||
path or a relocation flag. Path values are expanded, normalized, validated, and tested under at
|
||||
least two distinct home roots.
|
||||
|
||||
No command embeds home directories, script locations, provider endpoints, seat paths, or tmux socket
|
||||
names outside the resolver and its reviewed defaults.
|
||||
|
||||
### CFG-REQ-006: Schema evolution
|
||||
|
||||
A new key requires:
|
||||
|
||||
1. a named consumer.
|
||||
2. a `#1382` schema amendment.
|
||||
3. joint ACK from the frozen-schema and resolver-contract custodians until handoff, recorded by
|
||||
custodian-authored commits rather than relayed tokens alone.
|
||||
4. parser, invalid-input, default, and two-root tests.
|
||||
5. documentation in the same reviewed change.
|
||||
|
||||
Speculative keys are forbidden.
|
||||
|
||||
### CFG-REQ-007: Joint freeze evidence
|
||||
|
||||
The v1 resolver contract is jointly frozen:
|
||||
|
||||
- Fred, frozen-schema custodian, accepted C1 and C3 through token
|
||||
`CLI-T78-REGISTRY-FREEZE ACCEPT`, then accepted amended C2 through token
|
||||
`CLI-T78-REGISTRY-C2 ACCEPT`.
|
||||
- Homelab `orch-01`, issue and resolver-contract custodian, accepted C1 and C3 and supplied the
|
||||
adopted C2 rolling-version amendment in the fleet-comms repository, message
|
||||
`sites/usc/20260827T004509Z__to-vision__from-homelab.orch-01__683e4c.md`, blob
|
||||
`35a7c4c1e54eb9196abeef3135d211ee1dfc46db`.
|
||||
|
||||
Durable lane provenance is recorded in the Mosaic brain repository at
|
||||
`fleet/lanes/cli-migration/registry-freeze-evidence.md` and the independent custodian-authored
|
||||
`fleet/lanes/cli-migration/registry-freeze-fred-ack.md`. Schema evolution after this freeze still
|
||||
follows CFG-REQ-006.
|
||||
|
||||
## 8. Capability catalog and policy
|
||||
|
||||
### CAP-REQ-001: One typed catalog
|
||||
|
||||
Each capability definition records:
|
||||
|
||||
```ts
|
||||
type CapabilityEffect = 'read' | 'bounded-mutation' | 'privileged-mutation';
|
||||
|
||||
interface CapabilityDefinitionV1 {
|
||||
id: string;
|
||||
commandPath: readonly string[];
|
||||
effect: CapabilityEffect;
|
||||
targetSchema: string;
|
||||
inputSchema: string;
|
||||
outputSchema: string;
|
||||
credentialClass: string | null;
|
||||
requiredScopes: readonly string[];
|
||||
auditRequired: boolean;
|
||||
timeoutMs: number;
|
||||
idempotency: 'read' | 'required-key' | 'provider-native' | 'uncertain-no-retry';
|
||||
adapterId: string;
|
||||
deprecation: 'active' | 'deprecated' | 'removed';
|
||||
}
|
||||
```
|
||||
|
||||
The catalog is data consumed by the parser, help, policy, documentation, and tests. Command handlers
|
||||
must not maintain independent copies of these facts.
|
||||
|
||||
### CAP-REQ-002: Policy is not parser logic
|
||||
|
||||
Capability grants map verified actor identity and lane to capability IDs and resource scopes. They
|
||||
are data. A named seat receives no authority from its name alone.
|
||||
|
||||
The user-editable central registry is placement and endpoint configuration, not authorization
|
||||
policy. It MUST NOT contain lane grants or let a seat self-grant capability scope. Target authority
|
||||
lives in the `mosaicd` control-plane store outside seat containers and returns a policy revision and
|
||||
digest with every decision.
|
||||
|
||||
Before `mosaicd`, local compatibility mode may evaluate a package-owned policy for behavior and test
|
||||
parity, but it reports locally asserted identity and makes no broker-grade authorization claim. Mode
|
||||
selection is declared by topology and policy, never inferred from broker availability. A missing or
|
||||
unhealthy required broker returns `unavailable`. It never falls back to local mode.
|
||||
|
||||
A capability using a shared, service, operator, or admin credential is broker-only. Local mode may
|
||||
use only the acting seat's own credential against a registry endpoint. Privileged infrastructure,
|
||||
merge, deployment, identity, authorization, and secret-management cutover requires `mosaicd`. A
|
||||
future policy-store key or broker endpoint still requires CFG-REQ-006 and the `mosaicd` topology
|
||||
contract.
|
||||
|
||||
### CAP-REQ-003: Identity trust
|
||||
|
||||
CLI arguments and ordinary environment variables are actor hints, not authorization identity. The
|
||||
local adapter reports that identity is locally asserted and MUST NOT claim broker-grade
|
||||
authorization. `mosaicd` derives or verifies actor identity from the authenticated seat runtime.
|
||||
|
||||
### CAP-REQ-004: Positive and denied controls
|
||||
|
||||
Every capability test includes:
|
||||
|
||||
1. an allowed request with expected result.
|
||||
2. a denied request differing only in the relevant lane or scope.
|
||||
3. a malformed target or configuration denial.
|
||||
4. a credential-redaction assertion.
|
||||
5. a verdict-discrimination control that proves the test can fail.
|
||||
|
||||
## 9. Adapter and broker contract
|
||||
|
||||
### EXE-REQ-001: One capability request
|
||||
|
||||
```ts
|
||||
interface CapabilityRequestV1 {
|
||||
schemaVersion: 1;
|
||||
capabilityId: string;
|
||||
actorHint?: { seat?: string; lane?: string };
|
||||
target: Record<string, string | number | boolean | null>;
|
||||
arguments: Record<string, string | number | boolean | null>;
|
||||
correlationId: string;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Credential values and unbounded comment bodies are not serialized into audit-safe request metadata.
|
||||
Body content travels through a bounded private input channel appropriate to the adapter.
|
||||
|
||||
### EXE-REQ-002: Local compatibility adapter
|
||||
|
||||
The local adapter MAY call a reviewed in-process implementation or a private script adapter. It
|
||||
MUST preserve existing queue guards, wrapper-first behavior, credential resolution, response
|
||||
validation, and mutation uncertainty. It reports `executionMode: local-adapter` and
|
||||
`identityTrust: local-asserted`.
|
||||
|
||||
Private child adapters receive bodies and credentials only through stdin, owner-only temporary
|
||||
files, or inherited file descriptors, never child-process arguments. Captured child stderr, shell
|
||||
trace, and diagnostics are inside the redaction boundary. Local results always use
|
||||
`audit: { authority: 'none', recorded: false }`. A local event identifier is not authoritative
|
||||
audit evidence.
|
||||
|
||||
The local adapter is compatibility, not a sandbox or authorization claim.
|
||||
|
||||
### EXE-REQ-003: `mosaicd` broker adapter
|
||||
|
||||
The broker adapter sends the same logical request to `mosaicd` outside the seat container.
|
||||
`mosaicd` owns:
|
||||
|
||||
- authoritative seat identity, recorded in audit from the derived runtime identity rather than
|
||||
`actorHint`.
|
||||
- capability and scope authorization.
|
||||
- credential resolution.
|
||||
- operation execution.
|
||||
- output sanitization.
|
||||
- audit persistence.
|
||||
- bounded timeout and cancellation behavior.
|
||||
|
||||
A contradictory `actorHint` produces a diagnostic and never replaces the derived actor. Broker
|
||||
results report `identityTrust: runtime-verified`. `audit.recorded: true` is valid only after
|
||||
`mosaicd` confirms persistence and returns its event ID. Consumers verify authoritative evidence
|
||||
against the broker trail, not the seat-produced envelope alone.
|
||||
|
||||
The transport and endpoint are supplied by immutable container topology and the reviewed central
|
||||
registry contract. No command hard-codes a daemon socket.
|
||||
|
||||
### EXE-REQ-004: Packaged implementation boundary
|
||||
|
||||
The initial TypeScript layout is:
|
||||
|
||||
- `packages/mosaic/src/central-registry/` for `MosaicRegistryResolver`, schema, and provenance.
|
||||
- `packages/mosaic/src/capabilities/` for catalog, request, result, policy interfaces, and tests.
|
||||
- `packages/mosaic/src/capabilities/adapters/local/` for temporary local adapter modules.
|
||||
- `packages/mosaic/src/capabilities/adapters/mosaicd/` for the broker client seam.
|
||||
- `packages/mosaic/src/commands/git.ts`, with later first-class domain files following the same
|
||||
command pattern.
|
||||
|
||||
Remaining script implementations may be promoted under `packages/mosaic/framework/tools/` as
|
||||
private packaged adapters during transition. Their installed paths are resolver-owned and are not
|
||||
public command contracts. No production adapter imports or executes source from the brain working
|
||||
tree as the final path.
|
||||
|
||||
### EXE-REQ-005: Container boundary
|
||||
|
||||
The representative seat container has:
|
||||
|
||||
- one seat identity.
|
||||
- rootless execution.
|
||||
- read-only root filesystem, with explicit bounded writable mounts.
|
||||
- a read-only internal `~/.config/mosaic/config.json` supplied by topology.
|
||||
- no host credential tree.
|
||||
- no shared host or fleet tmux socket.
|
||||
- a dedicated per-seat tmux socket only for one named, reviewed temporary adapter with a stated
|
||||
removal stage.
|
||||
- no Docker, Podman, or other container-runtime socket.
|
||||
- no installed legacy tool tree mount.
|
||||
- network access limited to declared capability paths.
|
||||
|
||||
Container implementation is outside this mission. Contract and compatibility tests are inside it.
|
||||
|
||||
## 10. Communications portability
|
||||
|
||||
### COM-REQ-001: Transport-neutral public contract
|
||||
|
||||
Public communications capabilities use logical addresses, messages, correlation IDs, delivery
|
||||
status, and adapter diagnostics. Tmux pane, socket, retry, and draft details stay below the public
|
||||
contract.
|
||||
|
||||
### COM-REQ-002: Transitional semantics
|
||||
|
||||
The tmux adapter preserves the measured `rc=2` behavior: content reached a pane as a draft, so the
|
||||
operation is not retried automatically. Fleet-comms preserves durable cross-site message identity
|
||||
and acknowledgment behavior.
|
||||
|
||||
### COM-REQ-003: Future transport replacement
|
||||
|
||||
A Matrix or native transport implementation passes the same contract tests. Callers do not change
|
||||
command paths, capability IDs, or result interpretation when the adapter changes.
|
||||
|
||||
## 11. Canonical source and runtime integrity
|
||||
|
||||
### SRC-REQ-001: Reviewed baseline
|
||||
|
||||
The F11 baseline is commit `5be5825`. Inventory report `585f214`, code review `3fe8de7`, and
|
||||
security review `e270098` are the M0 evidence. Both reviews found no blocker.
|
||||
|
||||
### SRC-REQ-002: Required M1 corrections
|
||||
|
||||
Before expanding direct execution from the working tree:
|
||||
|
||||
1. fix the `check-helper-drift.sh` environment assignment that suppresses version diagnostics.
|
||||
2. strip 20 dangling Excalidraw `node_modules` symlinks.
|
||||
3. add `tools/**/node_modules/` to the brain `.gitignore`.
|
||||
4. keep the reviewed `package-lock.json` as the reproducible dependency contract.
|
||||
5. correct the baseline report's misleading path-count headline.
|
||||
6. move `ci-publish-watch.sh` credential headers from process arguments to curl stdin
|
||||
configuration when that suite is changed.
|
||||
|
||||
### SRC-REQ-003: Source is not installation
|
||||
|
||||
Runtime code is loaded from reviewed package or installed artifacts, not directly from a mutable
|
||||
multi-writer checkout as the final design. Any transitional direct execution requires:
|
||||
|
||||
- a protected-path review rule.
|
||||
- an accepted digest anchored outside the synced tree in reviewed package metadata or Stack source.
|
||||
- verification before execution, including every credential-helper invocation.
|
||||
- a periodic verifier whose mismatch alert reaches a human.
|
||||
- a stated removal point.
|
||||
|
||||
### SRC-REQ-004: Credential helper integrity
|
||||
|
||||
The host-wide git credential helper and its accepted pin cannot be replaceable by the same synced
|
||||
commit. Transition requires an independently anchored verifier and alert. Final state moves the
|
||||
helper into the reviewed runtime installation or another explicitly protected location.
|
||||
|
||||
## 12. Migration and decommission
|
||||
|
||||
### MIG-REQ-001: Consumer census
|
||||
|
||||
Inventory every direct caller of `~/.config/mosaic/tools`, grouped as:
|
||||
|
||||
- skills and guides.
|
||||
- hooks and generated harness configuration.
|
||||
- systemd units and timers.
|
||||
- launchers and provisioning.
|
||||
- tests and CI.
|
||||
- direct agent commands.
|
||||
- private tool-to-tool calls.
|
||||
- production consumers.
|
||||
|
||||
Each census run creates a fresh randomized planted legacy reference at a unique path and is valid
|
||||
only when the detector reports that run's exact plant. Every host at every site still running the
|
||||
installed legacy tree is censused independently. An empty result without the fresh control, or a
|
||||
zero from only one host, is not evidence.
|
||||
|
||||
### MIG-REQ-002: Risk-ordered waves
|
||||
|
||||
Migrate in this order:
|
||||
|
||||
1. read-only status, health, list, and view.
|
||||
2. bounded CI and communications.
|
||||
3. issue, pull-request, and milestone mutation.
|
||||
4. credentialed infrastructure.
|
||||
5. merge, deployment, identity, authorization, and secret management.
|
||||
|
||||
Each wave proves contract parity before consumer cutover. Waves 1 through 3 may use local mode with
|
||||
acting-seat credentials. Wave 4 cutover is broker-only when it uses a shared or service credential.
|
||||
Wave 5 cutover is always broker-only and begins only after the M6 `mosaicd` boundary gate passes.
|
||||
|
||||
### MIG-REQ-003: Protected consumers
|
||||
|
||||
- M365 credentials, AD status, and six production consumers remain Peggy-owned until exact signoff
|
||||
and timer-aware tests.
|
||||
- Fleet-doctor, seat-service, Woodpecker extras, and their units remain Veronica-owned until exact
|
||||
replacement proof and named handoff.
|
||||
- Brain guards are excluded from wholesale removal.
|
||||
- The active A2 hold applies to `tools/seat-service/` and
|
||||
`fleet/bin/launch-seat-claude.sh` only.
|
||||
- Fleet configuration issue `#758` retains its own normative contract and delivery DAG. T78 does
|
||||
not re-scope or absorb its missing `inspect` and `validate` verbs. T78 measures and consumes the
|
||||
stable fleet surface only after `#758` completion or an explicit owner handoff.
|
||||
|
||||
### MIG-REQ-004: Compatibility and deprecation
|
||||
|
||||
Compatibility shims are private and time-bounded. Each shim:
|
||||
|
||||
- names its public replacement.
|
||||
- preserves existing safety behavior.
|
||||
- emits a machine-detectable deprecation diagnostic without corrupting JSON output.
|
||||
- has a measured consumer and removal issue.
|
||||
- cannot be used to add new direct callers.
|
||||
|
||||
### MIG-REQ-005: Final removal
|
||||
|
||||
The installed `~/.config/mosaic/tools` script surface is removed only after:
|
||||
|
||||
1. all active consumers use official capabilities.
|
||||
2. the census reports zero with a firing planted control.
|
||||
3. Constitution and wrapper-first gates are mechanically enforced by the CLI path.
|
||||
4. systemd units are regenerated, daemon-reloaded, re-enabled, and behavior-tested.
|
||||
5. fleet-doctor state is preserved.
|
||||
6. clean install, upgrade, rollback, and stale-install tests pass.
|
||||
7. user, admin, developer, API, and migration documentation is current.
|
||||
|
||||
## 13. Testing requirements
|
||||
|
||||
### TST-REQ-001: Resolver
|
||||
|
||||
- exact schema-v1 valid fixture.
|
||||
- absent, null, exact-v1, and unknown-non-null `$schema` cases.
|
||||
- unknown top-level and nested key warnings with full-path diagnostics.
|
||||
- `mosaic registry validate` lint rejection of the same unknown-key fixture.
|
||||
- invalid URL, path, socket, and type failures.
|
||||
- every precedence branch, including present-empty and present-invalid override denial without
|
||||
fallback.
|
||||
- two valid roots.
|
||||
- container topology with a read-only internal registry and no host registry path.
|
||||
- no credential value accepted or emitted.
|
||||
- control proving the invalid fixture fails.
|
||||
|
||||
### TST-REQ-002: Capability catalog
|
||||
|
||||
- command and capability ID uniqueness.
|
||||
- every public command documented.
|
||||
- no orphan catalog record.
|
||||
- parser, policy, help, and docs consume the same definition.
|
||||
- unauthorized lane and scope denial.
|
||||
- unknown capability denial.
|
||||
- topology-selected mode never falls back when the required broker is unavailable.
|
||||
- shared, service, operator, and admin credential classes reject local mode.
|
||||
|
||||
### TST-REQ-003: Pilot
|
||||
|
||||
- issue list and view against a valid configured instance.
|
||||
- invalid instance and repository denial.
|
||||
- comment success with provider response-shape and body-digest confirmation.
|
||||
- comment denial before provider access.
|
||||
- post-request uncertainty without retry, plus provider-native same-key and
|
||||
`uncertain-no-retry` read-back reconciliation cases.
|
||||
- credential, cookie, token, comment-body, child-argv, captured-stderr, and shell-trace redaction.
|
||||
- local results prove `identityTrust: local-asserted` and `audit.recorded: false`.
|
||||
- broker-stub results prove derived-identity precedence and reject unconfirmed
|
||||
`audit.recorded: true`.
|
||||
- user-editable endpoint changes cannot redirect a shared or service credential.
|
||||
- local-adapter and broker-stub request/result seam parity at M3.
|
||||
- live local-adapter and `mosaicd` contract parity at M6.
|
||||
|
||||
### TST-REQ-004: Migration
|
||||
|
||||
- fresh randomized consumer-census plant detected independently on every affected host and site.
|
||||
- compatibility diagnostics in table and JSON modes.
|
||||
- systemd timer and restart behavior.
|
||||
- production M365/AD consumer probes.
|
||||
- fleet-doctor digest-state preservation.
|
||||
- clean install, upgrade, rollback, stale install, and greenfield operation.
|
||||
- representative container without legacy tools mounted.
|
||||
- representative container mounts no shared or fleet tmux socket, and any temporary tmux exception
|
||||
uses only the named adapter's dedicated per-seat socket.
|
||||
|
||||
### TST-REQ-005: Delivery gates
|
||||
|
||||
Every source card requires focused tests, repository quality gates, independent code review,
|
||||
security review for authorization, credentials, transport, or integrity surfaces, reviewed squash
|
||||
PR to `next`, terminal-green CI, and linked-issue closure.
|
||||
|
||||
## 14. Documentation requirements
|
||||
|
||||
The workstream updates in the same delivery sequence:
|
||||
|
||||
- official CLI help.
|
||||
- `docs/PRD.md` workstream pointer.
|
||||
- `docs/ROADMAP.md` parallel-track entry.
|
||||
- `docs/SITEMAP.md` requirements link.
|
||||
- user guide commands and deprecation behavior.
|
||||
- administrator configuration, migration, and recovery.
|
||||
- developer architecture, capability authoring, schemas, and adapter contracts.
|
||||
- API and machine-readable result schemas.
|
||||
- release notes.
|
||||
- T78 program-map and unified-roadmap records.
|
||||
|
||||
No command is public until its help, structured output, authorization behavior, and documentation
|
||||
are present.
|
||||
|
||||
## 15. Delivery stages
|
||||
|
||||
| Stage | Scope | Exit gate |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
|
||||
| M0 | Register mission, reconcile ownership, establish and review source baseline, publish requirements and tracking | Reviewed contract merged, dedicated milestone and task graph present |
|
||||
| M1 | Inventory consumers, normalize baseline, freeze registry resolver, capability catalog, policy, and runtime-integrity contracts | Typed interfaces and migration census reviewed |
|
||||
| M2 | Implement resolver, catalog, common result envelope, and adapter interface | Contract and two-root tests green |
|
||||
| M3 | Deliver pilot issue list, view, and comment | Allowed and denied controls, uncertainty behavior, docs, review, CI |
|
||||
| M4 | Migrate waves 1 through 3, prepare wave 4 private adapters without shared-credential cutover | Per-suite owner handoff and parity evidence |
|
||||
| M5 | Cut eligible consumers and generate harness policy | No new direct references, compatibility callers measured |
|
||||
| M6 | Prove representative container and `mosaicd` seam, then cut over shared-credential wave 4 and all wave 5 capabilities | Boundary, authorization, audit, and parity tests green |
|
||||
| M7 | Remove installed legacy script tree | Zero callers, migration and rollback evidence, docs and release gates complete |
|
||||
|
||||
## 16. Workstream acceptance
|
||||
|
||||
T78 completes only when:
|
||||
|
||||
1. the official TypeScript CLI exposes documented first-class capability groups.
|
||||
2. the central registry resolver and capability catalog are single typed authorities.
|
||||
3. two-root and container-topology tests prove no command-path hard-coding.
|
||||
4. authorization has allowed and denied situational evidence.
|
||||
5. agent-visible output, logs, and process arguments contain no credential values.
|
||||
6. local and `mosaicd` modes share one request and result contract and report their mode honestly.
|
||||
7. a representative rootless seat container performs granted operations without legacy tools,
|
||||
host credentials, or a container-runtime socket.
|
||||
8. tmux and fleet-comms can be replaced without changing public communications callers.
|
||||
9. the legacy consumer census reaches zero with a discriminating control.
|
||||
10. the installed `~/.config/mosaic/tools` script surface is removed.
|
||||
11. independent review passes for every source partition.
|
||||
12. all PRs are squash-merged to `next`, terminal CI is green, and linked issues are closed.
|
||||
|
||||
## 17. Contract-freeze status
|
||||
|
||||
The architecture inputs are frozen for independent review:
|
||||
|
||||
1. The central-registry resolver has joint C1, amended C2, and C3 approval.
|
||||
2. User-editable `config.json` is not authorization policy. Target grant authority belongs to
|
||||
`mosaicd`. Local mode is explicitly non-authoritative.
|
||||
3. Registry, capability, and adapter source boundaries are packaged TypeScript modules. Brain tools
|
||||
remain working source and temporary private adapters, not the final runtime contract.
|
||||
4. Issue `#758` remains an independent dependency and is not re-scoped into T78.
|
||||
|
||||
Provider tracking remains operationally blocked until the `orch-01` Mosaic Stack credential slot is
|
||||
minted. This does not weaken the contract or authorize implementation before reviewed publication.
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user