CI / build-and-test (push) Successful in 1m47s
* Implement tenant and user creation in IdentityPage * Add API calls for managing tenants and users * Introduce tenant-scoped API requests * Update sidebar to include identity navigation * Create BooleanStatusBadge component for key status
510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
import { GlobalStateStore } from './shellState';
|
|
import { currentTenantID, tenantScopeHeaders } from './tenantScope';
|
|
|
|
function authHeaders(): HeadersInit {
|
|
const token = GlobalStateStore.getState().session.authToken;
|
|
return { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...tenantScopeHeaders() };
|
|
}
|
|
|
|
type ResolveSpecResponse<T> = {
|
|
success: boolean;
|
|
data: T;
|
|
metadata?: unknown;
|
|
error?: { code: string; message: string; detail?: string };
|
|
};
|
|
|
|
type ResolveSpecFilter = {
|
|
column: string;
|
|
operator: string;
|
|
value?: unknown;
|
|
};
|
|
|
|
const TENANT_OWNED_RESOLVE_SPEC_ENTITIES = new Set([
|
|
'projects',
|
|
'thoughts',
|
|
'learnings',
|
|
'plans',
|
|
'stored_files',
|
|
'agent_skills',
|
|
'agent_guardrails',
|
|
'agent_personas',
|
|
'agent_parts',
|
|
'agent_traits',
|
|
'character_arcs',
|
|
'chat_histories'
|
|
]);
|
|
|
|
function resolveSpecEntity(path: string): string | null {
|
|
const match = path.match(/^\/api\/rs\/public\/([^/?]+)/);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function tenantScopedResolveSpecPayload(
|
|
path: string,
|
|
operation: 'read' | 'create' | 'update' | 'delete',
|
|
payload?: { data?: unknown; options?: unknown }
|
|
): { data?: unknown; options?: unknown } | undefined {
|
|
const entity = resolveSpecEntity(path);
|
|
const tenantID = currentTenantID();
|
|
if (!entity || !tenantID || !TENANT_OWNED_RESOLVE_SPEC_ENTITIES.has(entity)) return payload;
|
|
|
|
if (operation === 'create') {
|
|
const data = payload?.data;
|
|
return {
|
|
...payload,
|
|
// A selected tenant is authoritative; do not permit a form value to
|
|
// accidentally create data in a different tenant.
|
|
data: data && typeof data === 'object' && !Array.isArray(data) ? { ...data, tenant_id: tenantID } : data
|
|
};
|
|
}
|
|
|
|
const existingOptions = payload?.options && typeof payload.options === 'object' && !Array.isArray(payload.options)
|
|
? payload.options as Record<string, unknown>
|
|
: {};
|
|
const filters = Array.isArray(existingOptions.filters)
|
|
? existingOptions.filters.filter((filter): filter is ResolveSpecFilter =>
|
|
Boolean(filter) && typeof filter === 'object' && (filter as ResolveSpecFilter).column !== 'tenant_id')
|
|
: [];
|
|
|
|
return {
|
|
...payload,
|
|
options: {
|
|
...existingOptions,
|
|
filters: [...filters, { column: 'tenant_id', operator: 'eq', value: tenantID }]
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizeTags(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value.map((tag) => String(tag).trim()).filter(Boolean);
|
|
}
|
|
if (typeof value !== 'string') {
|
|
return [];
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return [];
|
|
|
|
// Handle Postgres text[] wire shape: "{tag1,tag2}".
|
|
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
return trimmed
|
|
.slice(1, -1)
|
|
.split(',')
|
|
.map((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
if (trimmed.includes(',')) {
|
|
return trimmed
|
|
.split(',')
|
|
.map((tag) => tag.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
return [trimmed];
|
|
}
|
|
|
|
async function get<T>(path: string): Promise<T> {
|
|
const res = await fetch(path, { headers: authHeaders() });
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
|
const res = await fetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
async function del(path: string): Promise<void> {
|
|
const res = await fetch(path, { method: 'DELETE', headers: authHeaders() });
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
}
|
|
|
|
async function patch<T>(path: string, body: unknown): Promise<T> {
|
|
const res = await fetch(path, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
async function rsCall<T>(
|
|
path: string,
|
|
operation: 'read' | 'create' | 'update' | 'delete',
|
|
payload?: { data?: unknown; options?: unknown }
|
|
): Promise<T> {
|
|
const scopedPayload = tenantScopedResolveSpecPayload(path, operation, payload);
|
|
const res = await fetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
body: JSON.stringify({
|
|
operation,
|
|
...(scopedPayload?.data !== undefined ? { data: scopedPayload.data } : {}),
|
|
...(scopedPayload?.options !== undefined ? { options: scopedPayload.options } : {})
|
|
})
|
|
});
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
const body = (await res.json()) as ResolveSpecResponse<T>;
|
|
if (!body.success) {
|
|
throw new Error(body.error?.message ?? 'ResolveSpec request failed');
|
|
}
|
|
return body.data;
|
|
}
|
|
|
|
function rsReadMany<T>(
|
|
entity: string,
|
|
options?: { filters?: ResolveSpecFilter[]; limit?: number; offset?: number; sort?: { column: string; direction: 'asc' | 'desc' }[] }
|
|
): Promise<T[]> {
|
|
return rsCall<T[] | null>(`/api/rs/public/${entity}`, 'read', {
|
|
options: {
|
|
...(options?.filters?.length ? { filters: options.filters } : {}),
|
|
...(options?.sort?.length ? { sort: options.sort } : {}),
|
|
...(options?.limit ? { limit: options.limit } : {}),
|
|
...(options?.offset ? { offset: options.offset } : {})
|
|
}
|
|
}).then((rows) => (Array.isArray(rows) ? rows : []));
|
|
}
|
|
|
|
export const api = {
|
|
projects: {
|
|
list: async (params?: { limit?: number; offset?: number; q?: string }) => {
|
|
type ProjectRow = {
|
|
guid: string;
|
|
name: string;
|
|
description: string | null;
|
|
created_at: string;
|
|
last_active_at: string;
|
|
thought_count?: number;
|
|
};
|
|
|
|
const filters: ResolveSpecFilter[] = [];
|
|
if (params?.q) {
|
|
filters.push({ column: 'name', operator: 'ilike', value: `%${params.q}%` });
|
|
}
|
|
|
|
const projectRows = await rsCall<ProjectRow[] | null>('/api/rs/public/projects', 'read', {
|
|
options: {
|
|
columns: ['guid', 'name', 'description', 'created_at', 'last_active_at'],
|
|
computedColumns: [
|
|
{
|
|
name: 'thought_count',
|
|
expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)'
|
|
}
|
|
],
|
|
...(filters.length ? { filters } : {}),
|
|
sort: [{ column: 'created_at', direction: 'desc' }],
|
|
limit: params?.limit ?? 100,
|
|
offset: params?.offset ?? 0
|
|
}
|
|
});
|
|
|
|
const projects = Array.isArray(projectRows) ? projectRows : [];
|
|
return projects.map((project) => ({
|
|
id: project.guid,
|
|
name: project.name,
|
|
description: project.description ?? '',
|
|
created_at: project.created_at,
|
|
last_active_at: project.last_active_at,
|
|
thought_count: project.thought_count ?? 0
|
|
}));
|
|
},
|
|
create: (name: string, description: string) =>
|
|
rsCall<import('./types').Project>('/api/rs/public/projects', 'create', {
|
|
data: { name, description }
|
|
}),
|
|
update: (id: string, data: { name?: string; description?: string }) =>
|
|
rsCall<void>(`/api/rs/public/projects/${id}`, 'update', { data }),
|
|
delete: (id: string) => rsCall<void>(`/api/rs/public/projects/${id}`, 'delete')
|
|
},
|
|
thoughts: {
|
|
list: (params: { q?: string; project_id?: string; limit?: number; offset?: number; include_archived?: boolean }) => {
|
|
const filters: ResolveSpecFilter[] = [];
|
|
if (params.q) {
|
|
filters.push({ column: 'content', operator: 'ilike', value: `%${params.q}%` });
|
|
}
|
|
if (params.project_id) {
|
|
filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
|
|
}
|
|
if (!params.include_archived) {
|
|
filters.push({ column: 'archived_at', operator: 'empty' });
|
|
}
|
|
return rsReadMany<import('./types').Thought>('thoughts', {
|
|
filters,
|
|
limit: params.limit ?? 100,
|
|
...(params.offset !== undefined ? { offset: params.offset } : {}),
|
|
sort: [{ column: 'created_at', direction: 'desc' }]
|
|
}).then((rows) =>
|
|
rows.map((row) => ({
|
|
...row,
|
|
content: typeof row.content === 'string' ? row.content : '',
|
|
metadata: {
|
|
...(row.metadata ?? {}),
|
|
people: row.metadata?.people ?? [],
|
|
action_items: row.metadata?.action_items ?? [],
|
|
dates_mentioned: row.metadata?.dates_mentioned ?? [],
|
|
topics: row.metadata?.topics ?? [],
|
|
type: row.metadata?.type ?? '',
|
|
source: row.metadata?.source ?? '',
|
|
metadata_status: row.metadata?.metadata_status ?? ''
|
|
}
|
|
}))
|
|
);
|
|
},
|
|
links: async (thoughtID: string) => {
|
|
const numericID = Number.parseInt(thoughtID, 10);
|
|
if (Number.isNaN(numericID)) return [];
|
|
|
|
const [outbound, inbound] = await Promise.all([
|
|
rsReadMany<import('./types').ThoughtLink>('thought_links', {
|
|
filters: [{ column: 'from_id', operator: 'eq', value: numericID }],
|
|
limit: 200,
|
|
sort: [{ column: 'created_at', direction: 'desc' }]
|
|
}),
|
|
rsReadMany<import('./types').ThoughtLink>('thought_links', {
|
|
filters: [{ column: 'to_id', operator: 'eq', value: numericID }],
|
|
limit: 200,
|
|
sort: [{ column: 'created_at', direction: 'desc' }]
|
|
})
|
|
]);
|
|
|
|
const byID = new Map<number, import('./types').ThoughtLink>();
|
|
for (const link of outbound) byID.set(link.id, link);
|
|
for (const link of inbound) byID.set(link.id, link);
|
|
|
|
return Array.from(byID.values()).sort(
|
|
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
);
|
|
},
|
|
get: (id: string) =>
|
|
rsCall<import('./types').Thought>(`/api/rs/public/thoughts/${id}`, 'read').then((row) => ({
|
|
...row,
|
|
content: typeof row.content === 'string' ? row.content : '',
|
|
metadata: {
|
|
...(row.metadata ?? {}),
|
|
people: row.metadata?.people ?? [],
|
|
action_items: row.metadata?.action_items ?? [],
|
|
dates_mentioned: row.metadata?.dates_mentioned ?? [],
|
|
topics: row.metadata?.topics ?? [],
|
|
type: row.metadata?.type ?? '',
|
|
source: row.metadata?.source ?? '',
|
|
metadata_status: row.metadata?.metadata_status ?? ''
|
|
}
|
|
})),
|
|
delete: (id: string) => rsCall<void>(`/api/rs/public/thoughts/${id}`, 'delete'),
|
|
archive: (id: string) =>
|
|
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', {
|
|
data: { archived_at: new Date().toISOString() }
|
|
}),
|
|
create: (data: { content: string; project_id?: string }) =>
|
|
rsCall<import('./types').Thought>('/api/rs/public/thoughts', 'create', { data }),
|
|
update: (id: string, data: { content?: string }) =>
|
|
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', { data })
|
|
},
|
|
skills: {
|
|
list: async (tag?: string) => {
|
|
const rows = await rsReadMany<Omit<import('./types').AgentSkill, 'tags'> & { tags?: unknown }>('agent_skills', {
|
|
filters: tag ? [{ column: 'tags', operator: 'contains', value: tag }] : undefined,
|
|
limit: 500,
|
|
sort: [{ column: 'created_at', direction: 'desc' }]
|
|
});
|
|
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
|
},
|
|
get: async (id: string) => {
|
|
const row = await rsCall<Omit<import('./types').AgentSkill, 'tags'> & { tags?: unknown }>(
|
|
`/api/rs/public/agent_skills/${id}`,
|
|
'read'
|
|
);
|
|
return { ...row, tags: normalizeTags(row.tags) };
|
|
},
|
|
create: (data: { name: string; description?: string; content: string; tags?: string[] }) =>
|
|
rsCall<import('./types').AgentSkill>('/api/rs/public/agent_skills', 'create', { data }),
|
|
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'delete'),
|
|
update: (id: string, data: Partial<import('./types').AgentSkill>) =>
|
|
rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'update', { data })
|
|
},
|
|
guardrails: {
|
|
list: async (params?: { tag?: string; severity?: string }) => {
|
|
const filters: ResolveSpecFilter[] = [];
|
|
if (params?.tag) filters.push({ column: 'tags', operator: 'contains', value: params.tag });
|
|
if (params?.severity) filters.push({ column: 'severity', operator: 'eq', value: params.severity });
|
|
|
|
const rows = await rsReadMany<Omit<import('./types').AgentGuardrail, 'tags'> & { tags?: unknown }>('agent_guardrails', {
|
|
filters,
|
|
limit: 500,
|
|
sort: [{ column: 'created_at', direction: 'desc' }]
|
|
});
|
|
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
|
},
|
|
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'delete'),
|
|
update: (id: string, data: Partial<import('./types').AgentGuardrail>) =>
|
|
rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'update', { data })
|
|
},
|
|
files: {
|
|
list: (params?: { project_id?: string; thought_id?: string; kind?: string }) => {
|
|
const filters: ResolveSpecFilter[] = [];
|
|
if (params?.project_id) filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
|
|
if (params?.thought_id) filters.push({ column: 'thought_id', operator: 'eq', value: params.thought_id });
|
|
if (params?.kind) filters.push({ column: 'kind', operator: 'eq', value: params.kind });
|
|
return rsReadMany<import('./types').StoredFile>('stored_files', {
|
|
filters,
|
|
limit: 500,
|
|
sort: [{ column: 'created_at', direction: 'desc' }]
|
|
});
|
|
}
|
|
},
|
|
maintenance: {
|
|
tasks: () =>
|
|
rsReadMany<import('./types').MaintenanceTask>('maintenance_tasks', {
|
|
limit: 200,
|
|
sort: [{ column: 'next_due', direction: 'asc' }]
|
|
}),
|
|
logs: () =>
|
|
rsReadMany<import('./types').MaintenanceLog>('maintenance_logs', {
|
|
limit: 200,
|
|
sort: [{ column: 'completed_at', direction: 'desc' }]
|
|
}),
|
|
runBackfill: (input?: {
|
|
project?: string;
|
|
limit?: number;
|
|
include_archived?: boolean;
|
|
older_than_days?: number;
|
|
dry_run?: boolean;
|
|
}) =>
|
|
post<import('./types').BackfillResult>('/api/admin/actions/backfill', {
|
|
limit: input?.limit ?? 100,
|
|
project: input?.project,
|
|
include_archived: input?.include_archived ?? false,
|
|
older_than_days: input?.older_than_days ?? 0,
|
|
dry_run: input?.dry_run ?? false
|
|
}),
|
|
runRetryMetadata: (input?: {
|
|
project?: string;
|
|
limit?: number;
|
|
include_archived?: boolean;
|
|
older_than_days?: number;
|
|
dry_run?: boolean;
|
|
}) =>
|
|
post<import('./types').MetadataRetryResult>('/api/admin/actions/retry-metadata', {
|
|
limit: input?.limit ?? 100,
|
|
project: input?.project,
|
|
include_archived: input?.include_archived ?? false,
|
|
older_than_days: input?.older_than_days ?? 1,
|
|
dry_run: input?.dry_run ?? false
|
|
})
|
|
},
|
|
identity: {
|
|
get: () => get<import('./types').IdentityData>('/api/admin/identity'),
|
|
createTenant: (name: string) =>
|
|
post<import('./types').Tenant>('/api/admin/identity/tenants', { name }),
|
|
adoptLegacy: (id: string) =>
|
|
post<unknown>(`/api/admin/identity/tenants/${id}/adopt-legacy`, {}),
|
|
createUser: (data: { tenant_id: string; name: string; email: string }) =>
|
|
post<import('./types').TenantUser>('/api/admin/identity/users', data),
|
|
createKey: (data: { tenant_id: string; user_id?: string; description: string }) =>
|
|
post<{ key: import('./types').IdentityKey; secret: string }>('/api/admin/identity/keys', data),
|
|
updateKey: (id: string, data: { tenant_id: string; user_id?: string | null; description?: string; enabled?: boolean }) =>
|
|
patch<import('./types').IdentityKey>(`/api/admin/identity/keys/${id}`, data)
|
|
},
|
|
plans: {
|
|
list: async (params?: { status?: string; priority?: string; project_id?: string; limit?: number }) => {
|
|
const filters: ResolveSpecFilter[] = [];
|
|
if (params?.status) filters.push({ column: 'status', operator: 'eq', value: params.status });
|
|
if (params?.priority) filters.push({ column: 'priority', operator: 'eq', value: params.priority });
|
|
if (params?.project_id) filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
|
|
|
|
const rows = await rsReadMany<Omit<import('./types').Plan, 'tags'> & { tags?: unknown }>('plans', {
|
|
filters,
|
|
limit: params?.limit ?? 500,
|
|
sort: [{ column: 'updated_at', direction: 'desc' }]
|
|
});
|
|
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
|
},
|
|
get: async (id: string) => {
|
|
const row = await rsCall<Omit<import('./types').Plan, 'tags'> & { tags?: unknown }>(
|
|
`/api/rs/public/plans/${id}`,
|
|
'read'
|
|
);
|
|
return { ...row, tags: normalizeTags(row.tags) };
|
|
},
|
|
create: (data: object) =>
|
|
rsCall<import('./types').Plan>('/api/rs/public/plans', 'create', { data }),
|
|
update: (id: string, data: Partial<import('./types').Plan>) =>
|
|
rsCall<void>(`/api/rs/public/plans/${id}`, 'update', { data }),
|
|
delete: (id: string) => rsCall<void>(`/api/rs/public/plans/${id}`, 'delete')
|
|
},
|
|
learnings: {
|
|
list: (params?: { limit?: number; offset?: number }) =>
|
|
rsReadMany<import('./types').Learning>('learnings', {
|
|
limit: params?.limit ?? 500,
|
|
...(params?.offset !== undefined ? { offset: params.offset } : {}),
|
|
sort: [{ column: 'created_at', direction: 'desc' }]
|
|
}),
|
|
get: (id: string) =>
|
|
rsCall<import('./types').Learning>(`/api/rs/public/learnings/${id}`, 'read'),
|
|
create: (data: object) =>
|
|
rsCall<import('./types').Learning>('/api/rs/public/learnings', 'create', { data }),
|
|
update: (id: string, data: Partial<import('./types').Learning>) =>
|
|
rsCall<void>(`/api/rs/public/learnings/${id}`, 'update', { data }),
|
|
delete: (id: string) => rsCall<void>(`/api/rs/public/learnings/${id}`, 'delete')
|
|
},
|
|
stats: async () => {
|
|
type StatsThoughtRow = {
|
|
metadata?: {
|
|
type?: string;
|
|
topics?: string[];
|
|
people?: string[];
|
|
};
|
|
};
|
|
|
|
const thoughts = await rsReadMany<StatsThoughtRow>('thoughts', {
|
|
limit: 5000,
|
|
sort: [{ column: 'created_at', direction: 'desc' }]
|
|
});
|
|
|
|
const typeCounts: Record<string, number> = {};
|
|
const topicCounts: Record<string, number> = {};
|
|
const peopleCounts: Record<string, number> = {};
|
|
|
|
for (const thought of thoughts) {
|
|
const type = thought.metadata?.type?.trim() || 'unknown';
|
|
typeCounts[type] = (typeCounts[type] ?? 0) + 1;
|
|
|
|
for (const topic of thought.metadata?.topics ?? []) {
|
|
const key = topic.trim();
|
|
if (!key) continue;
|
|
topicCounts[key] = (topicCounts[key] ?? 0) + 1;
|
|
}
|
|
|
|
for (const person of thought.metadata?.people ?? []) {
|
|
const key = person.trim();
|
|
if (!key) continue;
|
|
peopleCounts[key] = (peopleCounts[key] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
const topEntries = (map: Record<string, number>) =>
|
|
Object.entries(map)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10)
|
|
.map(([key, count]) => ({ key, count }));
|
|
|
|
const stats: import('./types').ThoughtStats = {
|
|
total_count: thoughts.length,
|
|
type_counts: typeCounts,
|
|
top_topics: topEntries(topicCounts),
|
|
top_people: topEntries(peopleCounts)
|
|
};
|
|
|
|
return stats;
|
|
}
|
|
};
|