Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b753a48a4 | ||
|
|
5c83c89bae | ||
|
|
2a223767b3 | ||
|
|
2a30c68b84 | ||
|
|
f8f8f97be7 | ||
|
|
49b7943420 | ||
|
|
19e16bd44f |
+27
-14
@@ -32,6 +32,11 @@ variables:
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
||||
# published gateway sha images with no web/appservice counterpart — no
|
||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
||||
# step now builds on next too (sha-only destinations, enforced per step).
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
@@ -44,16 +49,6 @@ variables:
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
@@ -474,7 +469,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -488,8 +483,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next appservice publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: appservice 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/appservice:$CI_COMMIT_TAG"
|
||||
@@ -509,7 +513,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -523,8 +527,17 @@ steps:
|
||||
- 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" = "main" ]; then
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'reflect-metadata';
|
||||
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { TeamsController } from './teams.controller.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
||||
|
||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
||||
|
||||
const teamsServiceMock = {
|
||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
||||
findAllForUser: vi.fn((userId: string) =>
|
||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
||||
),
|
||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
||||
isMember: vi.fn((teamId: string, userId: string) =>
|
||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
||||
),
|
||||
};
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
||||
requestContext.user = currentUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('teams endpoints are scoped to membership', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentUser = { id: 'user-1' };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha]);
|
||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns every team for an admin', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(teamAlpha);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
||||
expect(denied.status).toBe(403);
|
||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: false });
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('an admin can look up any membership', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: true });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,68 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
type RequestUser = { id: string; role?: string };
|
||||
|
||||
@Controller('api/teams')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teams: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.teams.findAll();
|
||||
async list(@CurrentUser() user: RequestUser) {
|
||||
if (user.role === 'admin') {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
return this.teams.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':teamId')
|
||||
async findOne(@Param('teamId') teamId: string) {
|
||||
return this.teams.findById(teamId);
|
||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
return this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
|
||||
@Get(':teamId/members')
|
||||
async listMembers(@Param('teamId') teamId: string) {
|
||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
return this.teams.listMembers(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members/:userId')
|
||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||
async checkMembership(
|
||||
@Param('teamId') teamId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
// A user may always ask about their own membership; anything else is
|
||||
// team-scoped like the other routes.
|
||||
if (userId !== user.id) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
const isMember = await this.teams.isMember(teamId, userId);
|
||||
return { isMember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-scoped access: admins see any team; everyone else only teams they
|
||||
* are a member of. NotFoundException when the team does not exist and
|
||||
* ForbiddenException when the user lacks access (same convention as the
|
||||
* projects controller).
|
||||
*/
|
||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
||||
const team = await this.teams.findById(teamId);
|
||||
if (!team) throw new NotFoundException('Team not found');
|
||||
if (user.role === 'admin') return team;
|
||||
const isMember = await this.teams.isMember(teamId, user.id);
|
||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -56,6 +56,21 @@ export class TeamsService {
|
||||
return this.db.select().from(teams);
|
||||
}
|
||||
|
||||
/**
|
||||
* List only the teams the user is a member of.
|
||||
*/
|
||||
async findAllForUser(userId: string) {
|
||||
const memberRows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, userId));
|
||||
|
||||
const teamIds = memberRows.map((r) => r.teamId);
|
||||
if (teamIds.length === 0) return [];
|
||||
|
||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
TasksRouteErrorBoundary,
|
||||
} from '@/spa/pages/resource-route-error-boundaries';
|
||||
import { TasksPage } from '@/spa/pages/tasks';
|
||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
import { SettingsPage } from '@/spa/pages/settings';
|
||||
import { AdminPage } from '@/spa/pages/admin';
|
||||
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
|
||||
function GuestLayout(): ReactElement {
|
||||
return (
|
||||
@@ -56,8 +57,11 @@ export const routes: RouteObject[] = [
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -21,3 +21,23 @@ export function AuthGuard(): ReactElement {
|
||||
|
||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export function AdminGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const user = session.user as typeof session.user & { role?: string };
|
||||
|
||||
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, useSessionMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
authClient: {},
|
||||
}));
|
||||
|
||||
import { AdminPage } from './admin';
|
||||
import { AdminGuard } from '@/spa/guards';
|
||||
|
||||
const userFixtures = {
|
||||
users: [
|
||||
{
|
||||
id: 'u-admin',
|
||||
name: 'Ada Admin',
|
||||
email: '[email protected]',
|
||||
role: 'admin',
|
||||
banned: false,
|
||||
banReason: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'u-member',
|
||||
name: 'Mel Member',
|
||||
email: '[email protected]',
|
||||
role: 'member',
|
||||
banned: true,
|
||||
banReason: 'spam',
|
||||
createdAt: '2026-08-02T00:00:00.000Z',
|
||||
updatedAt: '2026-08-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderAdminRoute(): Promise<void> {
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
{ path: '/', element: <div>home page</div> },
|
||||
{ path: '/login', element: <div>login page</div> },
|
||||
];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
|
||||
return {
|
||||
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
|
||||
isPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
it('redirects unauthenticated visitors to /login', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('login page');
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects non-admin users to /', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('member'));
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('home page');
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the admin page for admin users', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('Admin Panel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPage users tab', () => {
|
||||
it('lists users with role and ban status after load', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
|
||||
expect(container.textContent).toContain('Ada Admin');
|
||||
expect(container.textContent).toContain('Mel Member');
|
||||
expect(container.textContent).toContain('Banned');
|
||||
expect(container.textContent).toContain('2 user(s)');
|
||||
});
|
||||
|
||||
it('shows the load error with a retry control', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('gateway unavailable');
|
||||
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
const retry = [...container.querySelectorAll('button')].find((b) =>
|
||||
b.textContent?.includes('Retry'),
|
||||
);
|
||||
expect(retry).toBeTruthy();
|
||||
await act(async () => {
|
||||
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Ada Admin');
|
||||
});
|
||||
|
||||
it('posts to the ban endpoint and reloads on Ban', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValue(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
const banButton = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.textContent === 'Ban',
|
||||
);
|
||||
expect(banButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPage health tab', () => {
|
||||
it('loads health status when the tab is opened', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
database: { status: 'ok', latencyMs: 3 },
|
||||
cache: { status: 'ok', latencyMs: 1 },
|
||||
agentPool: { activeSessions: 2 },
|
||||
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
|
||||
checkedAt: '2026-08-26T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
const healthTab = [...container.querySelectorAll('button')].find((b) =>
|
||||
b.textContent?.includes('System Health'),
|
||||
);
|
||||
await act(async () => {
|
||||
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
|
||||
expect(container.textContent).toContain('Database (PostgreSQL)');
|
||||
expect(container.textContent).toContain('Active sessions: 2');
|
||||
expect(container.textContent).toContain('4 models');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
|
||||
// assumes an authenticated admin session.
|
||||
export function AdminPage(): 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,178 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
useSessionMock: vi.fn(),
|
||||
updateUserMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
authClient: { updateUser: updateUserMock },
|
||||
}));
|
||||
|
||||
import { SettingsPage } from './settings';
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
useSessionMock.mockReset();
|
||||
updateUserMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderSettingsPage(): Promise<void> {
|
||||
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): Promise<void> {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
return act(async () => {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const session = {
|
||||
user: { id: 'u-1', name: 'Test User', email: '[email protected]', image: null },
|
||||
};
|
||||
|
||||
describe('SettingsPage profile tab', () => {
|
||||
it('renders the profile form from the session and saves via authClient', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
updateUserMock.mockResolvedValue({});
|
||||
|
||||
await renderSettingsPage();
|
||||
|
||||
const nameInput = container.querySelector<HTMLInputElement>('#profile-name');
|
||||
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
|
||||
expect(nameInput?.value).toBe('Test User');
|
||||
expect(emailInput?.value).toBe('[email protected]');
|
||||
expect(emailInput?.disabled).toBe(true);
|
||||
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null });
|
||||
expect(container.textContent).toContain('Saved!');
|
||||
});
|
||||
|
||||
it('surfaces an update failure without clearing the form', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } });
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(container.textContent).toContain('name rejected');
|
||||
expect(container.querySelector<HTMLInputElement>('#profile-name')?.value).toBe('Test User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage appearance tab', () => {
|
||||
it('loads preferences and posts each changed preference on save', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
apiMock.mockImplementation((path: string) =>
|
||||
path.startsWith('/api/memory/preferences?')
|
||||
? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }])
|
||||
: Promise.resolve({}),
|
||||
);
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Appearance');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance');
|
||||
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage providers tab', () => {
|
||||
it('loads LLM and SSO providers and runs a connection test', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
apiMock.mockImplementation((path: string, opts?: { method?: string }) => {
|
||||
if (path === '/api/providers' && opts === undefined) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
available: true,
|
||||
models: [
|
||||
{
|
||||
id: 'llama3.2',
|
||||
provider: 'ollama',
|
||||
name: 'Llama 3.2',
|
||||
reasoning: false,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
inputTypes: ['text'],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (path === '/api/sso/providers') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (path === '/api/providers/test') {
|
||||
return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 });
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Providers');
|
||||
|
||||
expect(container.textContent).toContain('Ollama');
|
||||
expect(container.textContent).toContain('1 model');
|
||||
|
||||
await clickButtonByText('Test');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/providers/test', {
|
||||
method: 'POST',
|
||||
body: { providerId: 'ollama' },
|
||||
});
|
||||
expect(container.textContent).toContain('Reachable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,826 @@
|
||||
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 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);
|
||||
}
|
||||
@@ -882,3 +882,12 @@ 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.
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@ 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, and the other
|
||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, T78 CLI
|
||||
capability migration
|
||||
([requirements](./requirements/cli-capability-migration.md)), and the other
|
||||
Part II contracts in the PRD) run as parallel tracks under their own issues
|
||||
and are prerequisites where noted.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
- [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
|
||||
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,328 @@
|
||||
# Identity Account-Lifecycle Contract
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 4 of 9).
|
||||
Authority: PRD D10 (better-auth is the account system of record), Q1 ruled O1
|
||||
by Jason 2026-08-26 (webui-audit T10). This document turns that ruling into
|
||||
enforceable policy. It also carries the bootstrap/first-admin invariant from
|
||||
issue #1430, folded in here after PR #1431's independent review showed the
|
||||
quick-fix approach was insufficient.
|
||||
|
||||
Revision 2: addresses the 9 findings of the independent review
|
||||
(`fleet/lanes/webui-audit/findings/pr1433-review.md`) — epoch enforcement
|
||||
tightened (§3), canonical email split from provider claims (§5), external
|
||||
principal keyed by issuer+subject with DB uniqueness and link step-up (§6),
|
||||
JIT default precedence and first-admin SSO path defined (§2, §4),
|
||||
deactivation made measurable (§7.1), deletion kept in scope and the existing
|
||||
hard-delete endpoint required to fail closed (§7.3), workspace identity
|
||||
reconciled with the native-kanban SOT (§1.4), verification matrix expanded
|
||||
(§8), factual labels corrected (§7.3, §8.1).
|
||||
|
||||
Revision 3: addresses the residuals and new findings of the revision-2
|
||||
re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) —
|
||||
`users.emailVerified` added to the canonical set with a defined reset rule on
|
||||
email change (§5.1–5.2), external-principal uniqueness moved to
|
||||
(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped
|
||||
delete affordances (web admin page, `mosaic auth users delete`) required to
|
||||
be removed or disabled with a defined user-visible state (§7.3), the
|
||||
admin-creation switch removed in favor of plain admin authorization (§2.3),
|
||||
and §8 extended with observables for IdP removal, forward-auth non-use,
|
||||
first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and
|
||||
positive/expiry-bound step-up cases.
|
||||
|
||||
Scope: account creation, bootstrap, federated login, account linking, claim
|
||||
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
|
||||
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
|
||||
(contract 1), sensitive-data custody (contract 7 / D14).
|
||||
|
||||
## 1. System of record
|
||||
|
||||
1. better-auth's tables (`users`, `accounts`, `sessions`, `verifications`) are
|
||||
the only account system of record. All foreign keys reference `users.id`.
|
||||
2. External IdPs (Authentik or any OIDC provider) are login methods, attached
|
||||
through better-auth's generic-OAuth plugin (`packages/auth/src/sso.ts`).
|
||||
They never own accounts. Removing an IdP removes a login method, not users.
|
||||
3. The forward-auth perimeter shim is a deployment measure. Once in-app OIDC
|
||||
is configured for a deployment, the shim is demoted: it may stay as network
|
||||
perimeter, but no application code may read identity from its headers.
|
||||
4. **Account ≠ workspace membership.** Creating an account (by any path:
|
||||
bootstrap, sign-up, invite, JIT, admin creation) creates no workspace, no
|
||||
hierarchy grant, and no workspace-scoped authority (native-kanban SOT
|
||||
REQ-TEN-001 / REQ-ID-001). The better-auth `role` field is a platform/auth
|
||||
role (`member` | `admin`), not workspace membership. Workspace grants are
|
||||
defined by contract 2; until then a fresh account can authenticate and
|
||||
holds no workspace authority.
|
||||
|
||||
## 2. Registration gating
|
||||
|
||||
Measured current state on `next`: `emailAndPassword.enabled: true` with no
|
||||
gating — anyone who can reach the Gateway can create an account via
|
||||
`POST /api/auth/sign-up/email` and receives role `member`.
|
||||
|
||||
Contract:
|
||||
|
||||
1. A single server-side setting `registration_mode` with values
|
||||
`open | invite | closed`. It lives in the database (admin-mutable at
|
||||
runtime), not in env config.
|
||||
2. Default after bootstrap: `closed`. The wizard (contract 3) may set a
|
||||
different mode during setup, recorded as an explicit operator choice.
|
||||
While the bootstrap epoch is open (§3), the effective mode is `closed`
|
||||
regardless of any stored value: the setting takes effect only after the
|
||||
epoch completes.
|
||||
3. `closed` blocks self-service email/password sign-up. It does not block
|
||||
admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is
|
||||
gated by admin authorization alone — there is no separate switch for it.
|
||||
JIT is gated by its per-provider flag (§4.1). All user-creating paths are
|
||||
closed while the bootstrap epoch is open (§3).
|
||||
4. `invite` requires a single-use, expiring invite token bound to an email
|
||||
address. Invite issuance is an admin operation and is audit-logged.
|
||||
5. Enforcement point: a better-auth hook (or equivalent middleware executed
|
||||
inside the auth handler path), not a Gateway route guard in front of it —
|
||||
the raw `/api/auth/*` handler must be incapable of bypassing the gate.
|
||||
|
||||
## 3. Bootstrap / first-admin invariant (from #1430)
|
||||
|
||||
Invariant: **the system transitions from zero users to one admin user exactly
|
||||
once per bootstrap epoch, atomically, regardless of concurrency or which code
|
||||
path writes users.**
|
||||
|
||||
Constraints any implementation MUST satisfy (each traces to a verified defect
|
||||
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):
|
||||
|
||||
1. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
|
||||
lives in a constraint-backed one-row `bootstrap_state` table. While the
|
||||
epoch is open, every non-bootstrap user-creating writer — better-auth
|
||||
sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
|
||||
the writer's own path (better-auth hook for the raw handler; guard for
|
||||
admin routes). A partial unique index or a winning epoch-transition row is
|
||||
necessary but not sufficient on its own: neither stops an untagged insert
|
||||
from a writer that never consulted the epoch. Both layers are required:
|
||||
database-level transition safety (the epoch-completing write races safely
|
||||
and at most one wins) and writer-level refusal (no path can create a user
|
||||
without reading epoch state).
|
||||
2. **Atomic first-admin transition.** The admin user, its credential account,
|
||||
the initial admin token, and the epoch-completed transition commit in one
|
||||
database transaction or not at all. A better-auth call through
|
||||
`drizzleAdapter(db)` runs on the root pool and is NOT part of any caller
|
||||
transaction; it may be used inside the bootstrap transition only if the
|
||||
adapter is explicitly bound to the transaction handle. Otherwise the
|
||||
bootstrap writer must create the user rows itself within the transaction.
|
||||
3. **Pool safety.** No design may hold a pooled connection inside a
|
||||
transaction while awaiting a write that acquires a second connection from
|
||||
the same pool (`DB_POOL_MAX=1` is a supported configuration).
|
||||
4. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin
|
||||
epoch completes, re-running the wizard reconfigures the system but never
|
||||
re-opens the zero-user transition. "Setup already completed" is a stable,
|
||||
testable state, and factory-reset (a future, explicitly destructive
|
||||
operation) is the only way to open a new epoch.
|
||||
5. **No stranded partial outcome.** A failure at any point in the transition
|
||||
leaves nothing observable (no admin user without its token, no completed
|
||||
epoch without an admin) and setup remains retryable — this follows from
|
||||
§3.2 and is stated separately because it is the pre-existing failure mode
|
||||
the #1431 review verified.
|
||||
6. **First-admin via SSO (D4).** When the operator chooses SSO for the
|
||||
initial user, the wizard executes the OIDC login as part of the bootstrap
|
||||
transition itself: the bootstrap writer creates the account from the
|
||||
asserted identity inside the §3.2 transaction. This path is the bootstrap
|
||||
writer, not JIT — §4's JIT gate stays closed during the epoch and is not
|
||||
an obstacle to D4.
|
||||
|
||||
## 4. JIT provisioning (OIDC first login)
|
||||
|
||||
1. A successful OIDC login with no matching account creates a user
|
||||
just-in-time only when `jit_provisioning` is enabled for that provider.
|
||||
The flag is per-provider and defaults off, always. There is no
|
||||
mode-implied default: Enterprise setup enables JIT only when the wizard
|
||||
records it as an explicit operator choice for a named provider (this
|
||||
replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
|
||||
enabled", which contradicted the per-provider default).
|
||||
2. JIT users receive platform role `member`, never an elevated role,
|
||||
regardless of IdP claims (§5), and no workspace authority (§1.4).
|
||||
3. An optional per-provider email-domain allowlist constrains JIT. The
|
||||
allowlist matches only when the IdP asserts the email with
|
||||
`email_verified: true`; an unverified address never satisfies the
|
||||
allowlist. Empty allowlist with JIT on means any authenticated subject at
|
||||
that IdP gets an account — permitted, but the wizard must present it as an
|
||||
explicit choice.
|
||||
4. JIT is disabled while the bootstrap epoch is open (§3.1). The first-admin
|
||||
SSO path is §3.6, not JIT.
|
||||
|
||||
## 5. Claim mapping
|
||||
|
||||
1. **Two stores, not one.** Provider-observed claims (`email`,
|
||||
`email_verified`, display name, avatar) are recorded per external
|
||||
principal — keyed by issuer + subject (§6.1) — at first login and
|
||||
refreshed at each login. The canonical account fields (`users.email`,
|
||||
`users.emailVerified`, `users.name`, `users.image`) are set exactly once
|
||||
at account creation and are never silently overwritten by a later login.
|
||||
For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified`
|
||||
is set from the provider's `email_verified` claim at creation; for
|
||||
password-created accounts it is false until the address completes
|
||||
verification.
|
||||
2. **Canonical email changes only through an explicit workflow.** Either the
|
||||
user-initiated email change (with verification of the new address) or an
|
||||
admin edit. Any canonical email change — user- or admin-initiated — sets
|
||||
`users.emailVerified` to false until the new address completes
|
||||
verification; an admin may instead explicitly attest the address as
|
||||
verified in the same operation, and that attestation is audit-logged. A
|
||||
provider-claim refresh never rebinds `users.email` or
|
||||
`users.emailVerified`; a divergence between canonical email and the latest
|
||||
provider-observed email is surfaced per §6.4.
|
||||
3. Never mapped from IdP claims: `role` and any future authorization
|
||||
attribute. Authorization lives in the system of record and in the RBAC
|
||||
layer (contract 2). An IdP group/role claim may at most be recorded for
|
||||
audit; it grants nothing.
|
||||
|
||||
## 6. Account linking trust
|
||||
|
||||
1. **External principal identity is issuer + subject.** A linked identity is
|
||||
keyed by the OIDC issuer and subject claims, not by an unqualified
|
||||
provider subject id and not by email. The linked-identity row stores the
|
||||
issuer, and the database enforces at most one local account per
|
||||
**(issuer, subject)** with a unique constraint on those stored columns —
|
||||
uniqueness on (provider, subject) is insufficient because provider →
|
||||
issuer is not one-to-one: two provider configurations can point at the
|
||||
same issuer, and the identity must not alias across them. The current
|
||||
non-unique `(provider_id, account_id)` index satisfies neither;
|
||||
application-level checks without a uniqueness witness lose
|
||||
concurrent-callback races. Each configured provider additionally binds to
|
||||
exactly one issuer, immutable after creation (changing the issuer means
|
||||
creating a new provider).
|
||||
2. Linking an OIDC identity to an existing account happens only in one of two
|
||||
ways: (a) explicit link initiated by the logged-in user from settings,
|
||||
which requires step-up: a fresh reauthentication (password or existing
|
||||
linked method) no older than a short bound the implementation defines
|
||||
(≤ 10 minutes) — a session cookie alone is insufficient, so a stolen
|
||||
session cannot quietly attach a durable login method; or (b) automatic
|
||||
link when the IdP asserts a verified email exactly matching an existing
|
||||
account **and** the provider is marked `trusted_for_linking`
|
||||
(per-provider flag, default off).
|
||||
3. Untrusted-provider email collision produces a login error naming the
|
||||
conflict, not an auto-link and not a duplicate account.
|
||||
4. A linked identity whose IdP-observed email later diverges from the
|
||||
canonical account email keeps working (the link is by issuer + subject,
|
||||
§6.1) but the divergence is surfaced in the user's settings and audit log
|
||||
(the per-principal claim store in §5.1 is what makes the divergence
|
||||
representable).
|
||||
5. Unlinking a login method is refused when it would leave the account with
|
||||
no **usable** login method. Usable means: a set password, or a linked
|
||||
identity whose provider is currently configured and enabled on this
|
||||
deployment. A linked identity whose provider has been removed or disabled
|
||||
(§1.2) is not usable and does not count; setting a password first lifts
|
||||
the refusal.
|
||||
|
||||
## 7. Deactivation propagation
|
||||
|
||||
1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
|
||||
Concretely:
|
||||
- Ban and session revocation are one operation: the ban commit revokes all
|
||||
better-auth sessions for the user. If revocation partially fails, the
|
||||
ban itself must already be committed and every guard denies from that
|
||||
point (fail closed); the operation is retryable.
|
||||
- Every authenticated entry path checks banned state: HTTP session guards,
|
||||
the admin bearer-token path (which today does not test `banned` — an
|
||||
implementation defect this contract makes non-conformant), MCP, and
|
||||
Socket.IO.
|
||||
- Active socket connections are terminated or denied within 30 seconds of
|
||||
the ban commit, or at the next inbound message on that socket, whichever
|
||||
comes first (socket auth at connect-time only, as today, does not
|
||||
satisfy this).
|
||||
- The current admin ban route updates only the user row; it does not
|
||||
conform to this section until revocation and guard coverage land.
|
||||
- Admin tokens owned by the banned user are revoked in the same operation.
|
||||
2. Deactivation at an external IdP does not propagate automatically in this
|
||||
contract's scope (no SCIM). Operational rule: removing a user from the IdP
|
||||
without banning them in Mosaic leaves any password or other linked login
|
||||
method usable — the admin guide must state this. SCIM/webhook-driven
|
||||
propagation is future work and out of scope here.
|
||||
3. **Deletion is not deactivation, and deletion is gated here.** Account
|
||||
deletion semantics (FK fan-out across the 21 foreign-key constraints to
|
||||
`users.id`, spread over 19 referencing tables) require their own
|
||||
deletion-and-retention contract, chartered as an addition to the S2 list —
|
||||
contract 7 is the D14 sensitive-data custody contract and does not cover
|
||||
account deletion. Until that deletion contract is ratified: the existing
|
||||
hard-delete endpoint (`DELETE /api/admin/users/:id`) is disabled and fails
|
||||
closed, and deactivation is the only supported removal operation. A
|
||||
contract that merely declared deactivation "the only supported removal"
|
||||
while the endpoint stayed live would be false on its face.
|
||||
Disabling the endpoint alone is insufficient — its shipped callers must
|
||||
not be left as advertised operations that now fail generically:
|
||||
- The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx`
|
||||
and any SPA port of it) is removed, or replaced by a disabled control
|
||||
whose visible text states that deletion is unavailable pending the
|
||||
deletion-and-retention contract and points at deactivation.
|
||||
- The CLI command `mosaic auth users delete`
|
||||
(`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero
|
||||
with a message stating the same and naming the deactivation command.
|
||||
- Both surfaces expose deactivation as the supported operation.
|
||||
|
||||
## 8. Verification requirements
|
||||
|
||||
Every MUST above needs a bounded observable. The matrix:
|
||||
|
||||
1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
|
||||
distinct physical connections (pattern:
|
||||
`apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
|
||||
which runs in the `test` CI step against the `ci-postgres` PostgreSQL
|
||||
service — note that pattern multiplexes one pooled handle, so the tests
|
||||
here must explicitly open separate connections). Races to cover:
|
||||
setup-vs-setup, setup-vs-raw-sign-up, setup-vs-JIT, setup-vs-admin-create.
|
||||
Plus: liveness under `DB_POOL_MAX=1`; fault injection after each write in
|
||||
the transition (user, credential, token, epoch) proving nothing observable
|
||||
leaks and setup retries; wizard re-run after completion proving the
|
||||
zero-user transition never re-opens. Mocked-transaction specs are
|
||||
supplementary; they cannot prove serialization.
|
||||
2. **Registration gating (§2).** Spec coverage of all three modes against the
|
||||
raw `/api/auth/` handler path, not only Gateway controllers; invite
|
||||
lifecycle (single-use, expiry, email binding); effective-`closed` while
|
||||
the epoch is open regardless of stored mode.
|
||||
3. **JIT (§4).** Provider flag off → no account on first OIDC login; on →
|
||||
account with platform role `member` and no workspace grant; domain
|
||||
allowlist rejects an unverified email claim even when the domain matches;
|
||||
JIT refused while the epoch is open.
|
||||
4. **Claim mapping (§5).** Login refresh updates the per-principal claim
|
||||
store and touches none of the canonical fields (`users.email`,
|
||||
`users.emailVerified`, name, image); explicit email-change workflow is the
|
||||
only path that rebinds canonical email; every canonical email change
|
||||
resets `users.emailVerified` to false unless the admin attestation path
|
||||
is taken, and that attestation appears in the audit log.
|
||||
5. **Linking (§6).** Unique-constraint witness: concurrent first-login
|
||||
callbacks for the same (issuer, subject) yield exactly one account, and
|
||||
two provider configurations sharing one issuer cannot create two accounts
|
||||
for the same subject; trusted auto-link; untrusted collision error;
|
||||
step-up both ways: an explicit link succeeds immediately after a fresh
|
||||
reauthentication and is refused once the implementation's chosen bound
|
||||
(≤ 10 minutes) has elapsed, and refused with no reauthentication at all;
|
||||
unlink refusal when no remaining method is usable per §6.5, including the
|
||||
removed-provider case, and acceptance after a password is set; divergence
|
||||
surfaced after IdP email change.
|
||||
6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed
|
||||
(partial-failure injection); guard denial post-ban on each transport:
|
||||
HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated
|
||||
within the 30-second/next-message bound; banned user's admin tokens
|
||||
unusable; hard-delete endpoint returns a fail-closed error while the
|
||||
deletion contract is unratified; the admin web UI renders no live delete
|
||||
action (absent, or disabled with the §7.3 text) and `mosaic auth users
|
||||
delete` exits non-zero with the §7.3 message — both asserted by spec.
|
||||
7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal:
|
||||
deleting a provider configuration leaves every user row intact and every
|
||||
other login method working (spec over the provider-config removal path).
|
||||
Forward-auth non-use: with in-app OIDC configured, a request carrying
|
||||
forward-auth identity headers and no session is treated as anonymous —
|
||||
no code path derives identity from those headers (negative spec at the
|
||||
Gateway entry). First-admin SSO: the §3.6 transition commits account,
|
||||
token, and epoch atomically from the asserted identity, and fault
|
||||
injection mid-transition leaves nothing observable (same harness as §8.1).
|
||||
Wizard-recorded JIT choice: enabling JIT for a provider writes an
|
||||
explicit per-provider operator-choice record, and no mode selection
|
||||
enables it implicitly (assert the stored record, not UI behavior).
|
||||
8. **Documentation observable (§7.2).** The admin guide contains the
|
||||
IdP-removal-does-not-deactivate statement; verified by a docs assertion
|
||||
(content check in CI or an enumerated review-checklist item on the
|
||||
implementing PR) — a MUST about documentation needs a checkable artifact,
|
||||
not intent.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–8 as written, with one decision embedded: registration
|
||||
defaults to `closed` after bootstrap (§2.2) — say "agreed" or name the mode
|
||||
you want as the default.
|
||||
Reference in New Issue
Block a user