Compare commits

..
Author SHA1 Message Date
fred 9a3041b21a fix(mosaic): route the Invariant R pi version probe through the bounded-retry runner
ci/woodpecker/pr/ci Pipeline was successful
The version probe was a bare subprocess.run with a 10s timeout while the
registry probe already had run_pi_registry_command's 45s timeout and three
attempts for concurrent-Pi stalls. Under CI queue load the bare probe is the
step that times out: pipelines 2808-2810 and 2819-2820 all failed at
test_pi_carve_out_resolves_to_real_unshadowed_builtins with
'pi --version timed out after 10 seconds' on unrelated (including docs-only)
heads, while 2814/2816 passed between them. Reusing the existing runner gives
the version probe the same bounded retries and the same probe/infra failure
labeling; behavior on a version mismatch or nonzero exit is unchanged.
2026-08-26 19:26:11 -05:00
fred 2a30c68b84 docs: identity account-lifecycle contract (S2 contract 4) (#1433)
ci/woodpecker/push/publish Pipeline is running
2026-08-27 00:13:49 +00:00
fred f8f8f97be7 feat(web): port settings and admin surfaces into the SPA (Phase P4-2) (#1434)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-27 00:03:38 +00:00
9 changed files with 2090 additions and 291 deletions
+8 -4
View File
@@ -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 /> }],
},
],
},
];
+20
View File
@@ -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 />;
}
+206
View File
@@ -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');
});
});
+522
View File
@@ -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>
);
}
+178
View File
@@ -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');
});
});
+826
View File
@@ -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);
}
+328
View File
@@ -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.15.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 18 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.
-279
View File
@@ -1,279 +0,0 @@
# Deployment Mode and Conversion Contract (D3)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
Standalone and Enterprise, with the mode table (brains, user-data
isolation, secrets, conversion); Standalone → Enterprise conversion is
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
— the per-user brain split is optional in Standalone and keeping it is
the recommended default because it preserves forward-compatibility with
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
flow only; Enterprise conversion is explicitly deferred. PRD D3
federation clause — federation is intentionally not fully designed,
deferred, and nothing in v1 may foreclose it.
Revision 2 (luna review F1F7): the identity precondition restated in
identity-contract terms with a conversion-local acknowledgment record
this contract owns (F1); a durable, keyed preparation state with a
Standalone-safe representation rule, an in-transaction re-check fence,
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
directly without the contradictory non-exhaustiveness clause (F3); the
D14 boundary bound here with a stable column-allowlist witness instead
of delegated to an unratified layout (F4); the conversion witness
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
writer coverage imported concretely from contract 1 §6.3 with a named
schema, closed writer set, crafted-write probe, and mode-resolution
assertion (F6); the mode read command flagged as a §12.1 drafting
addition rather than a D8 mandate (F7). Ownership language aligned
with contract 3 revision 2: mode is recorded at bootstrap and read by
the wizard as input.
This contract binds the mode as a canonical platform property (§2), the
per-mode obligations and which contract owns each (§3), the conversion
transition (§4), the v1 non-foreclosure obligations (§5), and their
witnesses (§6). Domain semantics stay with their owning contracts:
wizard branching (contract 3 §2), identity/SSO
(`identity-lifecycle.md`), custody and per-user brain mechanics
(contract 7, `custody-schema.md`), tool mapping
(`tool-gateway-mapping.md`).
## 1. Definitions
1. **Mode**: the platform-wide deployment mode, exactly one of
`standalone` or `enterprise`. The vocabulary is closed in v1;
extension (e.g. a federation mode) is by amendment to this contract,
never ad hoc.
2. **Conversion**: the one-way transition `standalone → enterprise`.
No other mode transition exists.
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
must all hold before the mode record may change.
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
the migration of one secret to the Vault backend, or the partition
of one user's brain content (§4.3).
## 2. Mode is a canonical recorded property
1. Mode is recorded canonically in the platform database at bootstrap
as the operator's install-time choice (D3: modes are "chosen at
install time"). The record is a single-row keyed record
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
reference); this contract owns it, the bootstrap writer performs the
one v1 write (§6.2), and the wizard reads it as input (contract 3
§2.3). Mode is never derived from feature state (presence of Vault,
count of brains, count of users), and no component may infer a
different mode than the record states.
2. The record is readable by any authenticated user through a Gateway
command with CLI exposure. This read command is a **drafting
addition** ratified with this contract (PRD §12.1), not a D8
mandate: D8 binds only that any surface exposing the value goes
through official tooling. When a webUI surface consumes the read, a
mapping row is added to `tool-gateway-mapping.md` by amendment —
the same route §4.4 already binds for the conversion command.
Components branch on the read value only.
3. The record is immutable except by the §4 conversion transition.
Editing it by direct database access, config file, environment
variable, or wizard re-run is non-conformant (contract 3 §2.3:
changing mode later is conversion, not a wizard re-run).
## 3. Per-mode obligations (owner map)
The PRD mode table binds four rows; this contract assigns each an
owning contract so no obligation is unowned and none is bound twice:
| Obligation | Standalone | Enterprise | Owner |
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
The Standalone brains row states the default layout, not the only
valid one: the D14 per-user split is a MAY in Standalone with keeping
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
secrets are equally valid Standalone configuration. Both prepared
states are therefore themselves valid Standalone states — the fact
§4.3 relies on.
In Enterprise steady state, a flat-file secrets backend is
non-conformant; the platform refuses to start Enterprise-mode
components against a flat-file secrets configuration (fail-closed, not
warn-and-run).
## 4. Conversion transition
1. **Direction and terminality.** The only transition is
`standalone → enterprise`. `enterprise → standalone` does not exist:
there is no command, no admin override, and no support path. An
attempt is refused with the precondition/state error class of the
command envelope (`tool-gateway-mapping.md` §4.2).
2. **Preconditions (all verified before the record changes):**
- Secrets: OpenBao/Vault is configured and reachable, and every
required secret is served from the Vault backend — none from a
flat-file backend. Secret migration completes before conversion;
this contract does not define the migration tooling, only the
gate.
- Brains: the per-user brain split required by the Enterprise row of
§3 is established for **every** existing user (or the deployment
already kept the split, the D14 recommended default). Brain
partitioning mechanics are contract 7; this contract binds only
that the split is complete before the mode flips.
- Identity: at least one platform administrator account exists that
is active in identity-contract terms — authenticated capability,
not banned, not deactivated (identity §2, §5). And the conversion
request carries a **configuration acknowledgment**: the current
canonical values of registration mode and per-provider JIT
enablement (identity §2.2, §4.1), echoed back in the request. A
mismatch between the echoed values and the canonical values at
verification refuses the conversion. This acknowledgment record
is conversion-local, owned by this contract, and stored with the
§4.4 audit event as the precondition evidence; it adds no
identity-contract obligation and no mode-specific identity
default — identity's own defaults remain valid states.
3. **Preparation state and the flip boundary.** Preparatory work is
tracked durably: each preparation unit (§1.4) records its
completion in a preparation table keyed by (bootstrap epoch, unit
identity — the secret's path, the user's id), written in the same
transaction as the unit's own effect where the unit's backend
allows it, and reconciled from the backend's actual state where it
does not (a secret already served by Vault, a brain already split,
is complete regardless of the table). Units are at-most-once per
key and re-runnable across attempts. **Standalone-safe
representation:** every preparation unit moves the deployment into
a state that is itself valid Standalone configuration (§3 note), so
an interrupted preparation leaves a fully operational Standalone
deployment reading its state through the ordinary contracts — no
rollback, fencing, or special Standalone read path is needed, and
no component behavior may key on "preparation in progress".
**The flip:** one transaction that (a) locks the mode record, (b)
re-verifies every §4.2 precondition after acquiring the lock, and
(c) writes the mode record and the §4.4 audit event. Any re-check
failure aborts with no write. External state that changes after the
re-check but before commit is bounded by the transaction window;
an external backend (Vault) failing after conversion is an
Enterprise runtime fault handled by §3's fail-closed steady-state
rule, not a conversion defect. An interrupted or failed conversion
leaves the record `standalone` and the platform fully operational;
there is no intermediate mode and no half-converted state
observable through the record.
4. **Authority and audit.** Conversion is a platform-administrator
command carrying an explicit irreversibility acknowledgment in its
request (distinct from the §4.2 configuration acknowledgment). It
is an official Gateway/CLI command (D8): when built, it is added to
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
§3.3). The transition emits an audit event (actor, prior mode, new
mode, precondition evidence reference including the configuration
acknowledgment) in the same transaction as the record change; the
event survives indefinitely. A refused attempt emits a refusal
event naming the failed precondition class and actor, with no
mode-change event.
## 5. v1 obligations (non-foreclosure)
v1 ships Standalone only (D11); the conversion command is deferred
work. v1 still MUST:
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
refused value for bootstrap — v1 bootstrap accepts `standalone`
only. The wizard reads the record (contract 3 §2.3); nothing in v1
writes it after bootstrap.
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
record.
3. Not foreclose conversion: the v1 platform database holds no
sensitive user content — sensitive categories live in the owning
user's brain, and postgres holds structure, consent records, and
pointers only (the D14 boundary, PRD §7). Custody mechanics are
contract 7's; this contract binds the boundary itself here so v1
cannot ship a layout that makes the §4.2 brain precondition
unsatisfiable, and §6.3 gives it a stable witness that does not
depend on contract 7's internals. Conversion implementation
additionally requires contract 7 ratified.
4. Not foreclose federation: v1 components accept exactly the two §1.1
values wherever a mode value is parsed and refuse any other value
**before side effects** — a refused configuration, not undefined
behavior and not a crash mid-operation. Forward compatibility lives
in storage and architecture, not in parser speculation: the mode
record's storage is not structurally locked to two values (no
database-level two-value enum), and any future value (e.g. a
federation mode) is defined by a versioned amendment to this
contract before any component accepts it. The PRD defers
federation's shape entirely; this contract does not presume it
arrives as a third mode value.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Mode-record witness (v1):** after bootstrap the mode is readable
via the Gateway command and CLI and equals the bootstrap-recorded
choice; bootstrap with mode `enterprise` is refused; bootstrap with
any unknown mode value is refused before side effects (§5.4).
2. **Writer-coverage witness (v1):** the mode record's writer set is
closed by the same three-prong static assertion contract 1 §6.3(b)
defines — symbol, class-table literal, and raw-execution prongs
with its allowlist composition rules — scoped to the
`platform_mode` table, with a writer allowlist containing exactly
the bootstrap writer in v1 (and exactly plus the conversion command
at the conversion milestone). Companions: a crafted direct write
attempted in a test fails and leaves the record unchanged; a
mode-resolution assertion that no shipped component derives mode
from feature state (mode reads occur only through the §2.2 read
surface — static assertion over Gateway, CLI, bootstrap, and
repository sources).
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
style of contract 1 §6.2 that the platform database schema contains
no sensitive-content column — the §5.3 boundary — stable regardless
of contract 7's internals (contract 7 §7 carries the full custody
witnesses).
4. **No-downgrade witness (conversion milestone):** with mode
`enterprise`, a conversion request to `standalone` (and any crafted
mode-write) is refused with the precondition/state error class and
no record change.
5. **Precondition witnesses (conversion milestone),** each refused
with no record change and no partial mode effect, parameterized
over both OpenBao and Vault where secrets are involved:
(a) secrets backend unreachable; (b) one required secret still
flat-file backed (migration incomplete); (c) one unpartitioned user
brain in a **multi-user** deployment where every other user is
partitioned; (d) no active platform administrator (the only admin
banned or deactivated); (e) configuration acknowledgment missing or
mismatching the canonical registration/JIT values; (f) actor not a
platform administrator (authorization refusal); (g) irreversibility
acknowledgment absent. And the steady-state rule: an
Enterprise-mode component started against a flat-file secrets
configuration refuses to start (§3).
6. **Interruption and fence witnesses (conversion milestone):** fault
injection aborting conversion after each preparation unit and
between preparation and flip leaves the record `standalone` and the
platform operational in Standalone semantics (§4.3
Standalone-safety), and a re-attempt completes without duplicating
prepared state (at-most-once keys); a precondition invalidated
after preparation but before the flip (a secret reverted to
flat-file) is caught by the in-transaction re-check and refused.
7. **Audit witnesses (conversion milestone):** a completed conversion
has exactly one mode-change audit event, same-transaction with the
record change (transaction linkage asserted), carrying actor, prior
mode, new mode, and the precondition evidence reference including
the configuration acknowledgment; a failed attempt has a refusal
event naming the failed precondition class and no mode-change
event; the mode-change event remains queryable after subsequent
unrelated audit activity (retention probe).
8. **Mapping witness (conversion milestone):** the conversion command
and the mode read command each have their
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
before the commands ship.
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§5): v1 implements the **mode record and its immutability
only** — bootstrap records `standalone`, the `enterprise` value is
reserved and refused, and the conversion command itself is deferred
to the Enterprise milestone, consistent with D11's deferred list.
v1 carries three obligations beyond the record: the closed writer
assertion, the D14 column boundary, and the unknown-value refusal
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
conversion work. Alternative if rejected: build the conversion
command inside v1 — rejected because D11 scopes v1 to the Standalone
slice and conversion depends on contract 7 custody mechanics that
are themselves not in the v1 slice.
@@ -75,7 +75,7 @@ def run_pi_registry_command(
runner=subprocess.run,
sleeper=time.sleep,
) -> subprocess.CompletedProcess[str]:
"""Run the registry probe with bounded retries for concurrent-Pi stalls."""
"""Run a Pi probe command with bounded retries for concurrent-Pi stalls."""
for attempt in range(1, PI_PROBE_ATTEMPTS + 1):
try:
@@ -106,13 +106,7 @@ def probe_pi_registry() -> list[dict[str, object]]:
if pi is None:
raise AssertionError("installed Pi runtime is required for Invariant R")
version = subprocess.run(
[pi, "--version"],
check=False,
capture_output=True,
text=True,
timeout=10,
)
version = run_pi_registry_command([pi, "--version"], dict(os.environ))
if version.returncode != 0:
raise AssertionError(f"Pi version probe failed: {version.stderr.strip()}")
if version.stdout.strip() != PI_VERSION: