Compare commits

...

8 Commits

Author SHA1 Message Date
hongjr03 ae5f78f036 chore: release v0.0.28
Exempt SPA static assets and admin HTML shell from silo HTTP rate limit so
page loads no longer exhaust HUB_HTTP_REQUESTS_PER_MINUTE.
2026-07-15 22:33:07 +08:00
hongjr03 0782e155f6 chore: release v0.0.27
Agent role/skill admin restore, OAuth login URL fixes, project create 404 fix.
2026-07-15 21:35:26 +08:00
ChickenPige0n 0726dc13c8 feat(admin): restore org Agent role/skill management and fix 404 after project create
- explorer POST /projects now returns {id,name} matching the SPA contract
  (previously returned ProjectOnboardingResult.projectId, so res.id was
  undefined and the redirect to /projects/undefined 404'd)
- add OrganizationAgentConfiguration.listRoles/listSkills + AgentRoleRow/
  AgentSkillRow exports; upsertRole now returns the full row
- new agentConfigRoutes: GET/PUT /agent-roles, PUT /agent-roles/:id/skills,
  GET /agent-skills, GET /agent-models (env-default picker)
- restore admin-web roles page + RoleCard rewired to ADR-0017/0018 backend
  (label, defaultModel, tools whitelist, skill binding, systemPrompt,
  sortOrder, default toggle); add 角色 nav item + roles icon
- skill installation stays out-of-band (CLI/seed) per spec; the surface only
  lists installed skills and binds them to roles
2026-07-15 18:19:47 +08:00
ChickenPige0n fb66614e38 fix(admin-web): break /admin/login returnTo redirect loop in dev
vite dev only proxied /api and /auth, so /admin/login hit the SPA root
layout, re-ran loadSession, got 401, and redirected back to /admin/login
with an ever-nesting returnTo. Proxy /admin/login to the backend (which
owns it before the SPA fallback) and guard redirectToLogin against
re-entering /admin/login.

Replace the only emoji-as-icon (the back-arrow on project detail) with
the Icon component (new arrow-left glyph).

Add scripts/dev-bootstrap.ts for seeding a local Silo (stub probes) so
npm run dev can start on a fresh dev DB.
2026-07-15 18:19:47 +08:00
hongjr03 11de9e81db fix(hub): redirect Feishu OAuth default login to org admin SPA
Default returnTo=/admin previously landed on the static complete page
meant for chat onboarding; send users to /admin/org/:slug instead.
2026-07-15 14:29:42 +08:00
hongjr03 46ce942aec fix(admin-web): use org-scoped Feishu OAuth login URL
Unscoped GET /auth/feishu is disabled by default. Derive org slug from
/admin/org/:slug, ?org=, or the Alpha Silo hostname and redirect to
/auth/feishu/:orgSlug so the login button works on tenant domains.
2026-07-15 00:52:58 +08:00
hongjr03 8990277916 chore: release v0.0.26
Org admin SPA, capacity policy admin, fleet deploy CI for educraft/educraft-dev.
2026-07-15 00:29:31 +08:00
hongjr03 b217c16c1b Merge branch 'admin-panel': org admin SPA and fleet deploy CI
Bring in the org-admin Svelte SPA, capacity policy surface, production SPA
serving, project MANAGE fixes, and Gitea deploy of educraft/educraft-dev fleets.
2026-07-15 00:29:30 +08:00
20 changed files with 807 additions and 33 deletions
+55 -4
View File
@@ -226,6 +226,39 @@ export interface CapacityPolicyView {
dimensions: CapacityDimensionRow[];
}
export interface AgentRoleRow {
id: string;
roleId: string;
label: string;
defaultModel: string | null;
systemPrompt: string | null;
tools: readonly string[] | null;
sortOrder: number;
isDefault: boolean;
disabledAt: string | null;
createdAt: string;
updatedAt: string;
skillNames: readonly string[];
}
export interface AgentSkillRow {
id: string;
name: string;
version: string;
description: string | null;
contentDigest: string;
disabledAt: string | null;
createdAt: string;
updatedAt: string;
boundRoleIds: readonly string[];
}
export interface AgentModelRow {
id: string;
label: string;
toolCapable: boolean;
}
// --- API ---
export const api = {
@@ -261,8 +294,7 @@ export const api = {
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
myProjects: (slug: string) =>
get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
myProjects: (slug: string) => get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
post(`${orgBase(slug)}/folders`, body) as Promise<{
id: string;
@@ -338,8 +370,27 @@ export const api = {
disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
capacityPolicy: (slug: string) =>
get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
agentRoles: (slug: string) => get(`${orgBase(slug)}/agent-roles`) as Promise<{ roles: AgentRoleRow[] }>,
upsertAgentRole: (
slug: string,
roleId: string,
body: {
label: string;
defaultModel?: string | null;
systemPrompt?: string | null;
tools?: readonly string[] | null;
sortOrder?: number;
isDefault?: boolean;
},
) => put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}`, body) as Promise<AgentRoleRow>,
setAgentRoleSkills: (slug: string, roleId: string, skillNames: readonly string[]) =>
put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/skills`, { skillNames }) as Promise<{
skillNames: string[];
}>,
agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>,
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
};
+15 -1
View File
@@ -15,9 +15,11 @@
| 'logout'
| 'org'
| 'chevron'
| 'arrow-left'
| 'folder'
| 'file'
| 'check';
| 'check'
| 'roles';
class?: string;
} = $props();
</script>
@@ -100,6 +102,10 @@
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{:else if name === 'arrow-left'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
{:else if name === 'folder'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
@@ -120,4 +126,12 @@
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{:else if name === 'roles'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.847-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.847.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.456-2.456L14.25 6l1.035-.259a3.375 3.375 0 002.456-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
{/if}
@@ -0,0 +1,208 @@
<script lang="ts">
import { Checkbox, Label } from 'bits-ui';
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let {
r,
models,
skills,
slug,
onupdated,
onskillschanged,
}: {
r: AgentRoleRow;
models: AgentModelRow[];
skills: AgentSkillRow[];
slug: string;
onupdated: (updated: AgentRoleRow) => void;
onskillschanged: (roleId: string, skillNames: string[]) => void;
} = $props();
const initial = {
label: r.label,
defaultModel: r.defaultModel ?? '',
systemPrompt: r.systemPrompt ?? '',
unrestricted: r.tools === null,
tools: r.tools ?? [],
sortOrder: String(r.sortOrder),
isDefault: r.isDefault,
skillNames: r.skillNames,
};
let label = $state(initial.label);
let defaultModel = $state(initial.defaultModel);
let systemPrompt = $state(initial.systemPrompt);
let unrestricted = $state(initial.unrestricted);
let selectedTools = $state<string[]>([...initial.tools]);
let sortOrder = $state(initial.sortOrder);
let isDefault = $state(initial.isDefault);
let selectedSkills = $state<string[]>([...initial.skillNames]);
let saving = $state(false);
const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => {
(acc[t.group] ??= []).push(t);
return acc;
},
{} as Record<string, typeof TOOL_OPTIONS>,
);
const modelItems = $derived([
{ value: '', label: '(使用平台默认模型)' },
...models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` })),
]);
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
function skillsDirty(): boolean {
const a = [...selectedSkills].sort();
const b = [...r.skillNames].sort();
return a.length !== b.length || a.some((v, i) => v !== b[i]);
}
async function save() {
const trimmedLabel = label.trim();
if (trimmedLabel === '') {
toastError('显示名不能为空');
return;
}
const order = Number(sortOrder);
if (sortKeyDirty() && (!Number.isSafeInteger(order) || order < 0)) {
toastError('排序必须为非负整数');
return;
}
saving = true;
const tools = unrestricted ? null : selectedTools;
try {
const updated = await api.upsertAgentRole(slug, r.roleId, {
label: trimmedLabel,
defaultModel: defaultModel === '' ? null : defaultModel,
systemPrompt: systemPrompt === '' ? null : systemPrompt,
tools,
...(sortKeyDirty() ? { sortOrder: order } : {}),
isDefault,
});
onupdated(updated);
if (skillsDirty()) {
const res = await api.setAgentRoleSkills(slug, r.roleId, selectedSkills);
selectedSkills = [...res.skillNames];
onskillschanged(r.roleId, res.skillNames);
}
toastSuccess('角色已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
function sortKeyDirty(): boolean {
return Number(sortOrder) !== r.sortOrder;
}
</script>
<div class="saas-card-pad">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="saas-badge-primary font-mono">/{r.roleId}</span>
<span class="text-sm text-surface-700">{label || r.label}</span>
{#if r.isDefault}
<span class="saas-badge-success">默认</span>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label.Root for="role-label-{r.id}" class="saas-label">显示名</Label.Root>
<input id="role-label-{r.id}" class="saas-input" bind:value={label} />
</div>
<div>
<Label.Root for="role-sort-{r.id}" class="saas-label">排序</Label.Root>
<input id="role-sort-{r.id}" class="saas-input" type="number" min="0" bind:value={sortOrder} />
</div>
</div>
<div class="mt-4">
<p class="saas-label">默认模型</p>
<SelectField items={modelItems} bind:value={defaultModel} />
</div>
<div class="mt-4">
<span class="saas-label">工具白名单</span>
<label class="mb-3 flex cursor-pointer items-center gap-2 border border-surface-300 bg-surface-100 px-3 py-2">
<CheckboxControl bind:checked={unrestricted} />
<span class="text-sm">不限(使用全部注册工具)</span>
</label>
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
<Checkbox.Group bind:value={selectedTools} disabled={unrestricted}>
{#each Object.entries(groupedTools) as [group, tools]}
<div>
<p class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-surface-600">{group}</p>
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each tools as t}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<Checkbox.Root class="saas-checkbox" value={t.id} id={`tool-${r.id}-${t.id}`}>
{#snippet children({ checked })}
{#if checked}
<Icon name="check" class="h-3.5 w-3.5" />
{/if}
{/snippet}
</Checkbox.Root>
<span>{t.label}</span>
</label>
{/each}
</div>
</div>
{/each}
</Checkbox.Group>
</div>
</div>
<div class="mt-4">
<span class="saas-label">技能绑定</span>
{#if skills.length === 0}
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。</p>
{:else}
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each skillItems as s}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<CheckboxControl
checked={selectedSkills.includes(s.value)}
onchange={(checked) => {
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
}}
/>
<span class="font-mono text-xs">{s.label}</span>
</label>
{/each}
</div>
{/if}
</div>
<div class="mt-4">
<Label.Root for="role-prompt-{r.id}" class="saas-label">系统提示词</Label.Root>
<textarea
id="role-prompt-{r.id}"
class="saas-textarea"
rows="4"
placeholder="系统提示词(可选)。会话开始时注入,定义智能体人格/指令。"
bind:value={systemPrompt}></textarea>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<label class="flex cursor-pointer items-center gap-2">
<CheckboxControl bind:checked={isDefault} />
<span class="text-sm">设为组织默认角色</span>
</label>
<span class="text-xs text-surface-600">更新于 {fmtDate(r.updatedAt)}</span>
<div class="flex-1"></div>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
+30 -2
View File
@@ -32,9 +32,37 @@ export async function loadSession(): Promise<void> {
}
}
/**
* Resolve org slug for org-scoped Feishu OAuth (`GET /auth/feishu/:orgSlug`).
* Unscoped `/auth/feishu` is disabled unless allowLegacyFeishuOAuth is on.
*/
export function resolveLoginOrgSlug(): string | null {
const path = window.location.pathname.split('/').filter(Boolean);
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
return decodeURIComponent(path[2]);
}
const q = new URLSearchParams(window.location.search).get('org');
if (q && q.trim() !== '') return q.trim();
// Alpha Silo public host: <slug>.educraft.paradigm-edu.net (or educraft-dev)
const host = window.location.hostname.toLowerCase();
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
if (m?.[1]) return m[1];
return null;
}
export function redirectToLogin(): void {
const ret = encodeURIComponent(window.location.pathname + window.location.hash);
window.location.href = `/auth/feishu?returnTo=${ret}`;
if (window.location.pathname === '/admin/login') return;
const ret = encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash,
);
const slug = resolveLoginOrgSlug();
if (slug === null) {
// Need an org slug for scoped OAuth — backend login helper can prompt.
window.location.href = `/admin/login?returnTo=${ret}`;
return;
}
window.location.href = `/auth/feishu/${encodeURIComponent(slug)}?returnTo=${ret}`;
}
export async function logout(): Promise<void> {
+2 -5
View File
@@ -25,6 +25,7 @@
{ key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'capacity', label: '容量', icon: 'overview' as const },
{ key: 'provider', label: '供应方', icon: 'provider' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
];
@@ -310,11 +311,7 @@
<div class="saas-shell">
<div class="saas-main">
<header class="saas-topbar">
<a
href={`/admin/org/${org.slug}/projects`}
class="saas-btn-ghost px-2!"
aria-label="返回项目列表"
>
<a href={`/admin/org/${org.slug}/projects`} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
<Icon name="menu" class="h-5 w-5" />
</a>
<div class="min-w-0">
@@ -15,6 +15,7 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
@@ -158,7 +159,8 @@
href={`/admin/org/${slug}/projects`}
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600"
>
← 返回项目列表
<Icon name="arrow-left" class="h-4 w-4" />
返回项目列表
</a>
</div>
@@ -0,0 +1,124 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]);
let skills = $state<AgentSkillRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newRoleId = $state('');
let newLabel = $state('');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const [r, m, s] = await Promise.all([api.agentRoles(slug), api.agentModels(slug), api.agentSkills(slug)]);
roles = r.roles;
models = m.models;
skills = s.skills;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function add() {
const roleId = newRoleId.trim();
const label = newLabel.trim();
if (roleId === '' || label === '') {
toastError('角色 ID 与显示名均为必填');
return;
}
if (roles.some((r) => r.roleId === roleId)) {
toastError(`角色 ID 已存在:${roleId}`);
return;
}
adding = true;
try {
const created = await api.upsertAgentRole(slug, roleId, { label });
roles = [...roles, created];
newRoleId = '';
newLabel = '';
toastSuccess('角色已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
function onRoleUpdated(updated: AgentRoleRow) {
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
if (updated.isDefault) {
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
}
}
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="角色"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="角色 ID(如 draft"
bind:value={newRoleId}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。</p>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
</div>
{:else}
<div class="space-y-4">
{#each roles as r (r.roleId)}
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
{/each}
</div>
{/if}
{/if}
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
proxy: {
'/api': 'http://127.0.0.1:8788',
'/auth': 'http://127.0.0.1:8788',
// Backend owns /admin/login (registered before the SPA fallback in
// src/admin/static.ts). Proxy it in dev so the SPA doesn't re-render
// its layout on that path and 401-redirect into a returnTo loop.
'/admin/login': 'http://127.0.0.1:8788',
},
},
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.25",
"version": "0.0.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.25",
"version": "0.0.28",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.25",
"version": "0.0.28",
"private": true,
"type": "module",
"engines": {
+52
View File
@@ -0,0 +1,52 @@
import { config } from "dotenv";
config();
import { PrismaClient } from "@prisma/client";
import { bootstrapAlphaSilo } from "../src/deployment/bootstrap-silo.js";
import { loadLocalSecretKeyring, LocalSecretEnvelope } from "../src/security/secretEnvelope.js";
async function main(): Promise<void> {
const keyringFile = process.env["HUB_SECRET_KEYRING_FILE"];
if (!keyringFile) throw new Error("HUB_SECRET_KEYRING_FILE is required");
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring());
const prisma = new PrismaClient();
try {
const result = await bootstrapAlphaSilo(
prisma,
secrets,
{
organization: {
id: "org_default",
slug: "default",
name: "Default Organization",
},
owner: {
openId: process.env["FEISHU_BOT_OPEN_ID"] ?? "dev-owner",
displayName: "Dev Owner",
},
feishu: {
appId: process.env["FEISHU_APP_ID"] ?? "dev-app",
appSecret: process.env["FEISHU_APP_SECRET"] ?? "dev-secret",
botOpenId: process.env["FEISHU_BOT_OPEN_ID"] ?? "dev-bot",
},
provider: {
providerId: "openrouter",
baseUrl: process.env["ANTHROPIC_BASE_URL"] ?? "https://openrouter.ai/api",
authToken: process.env["ANTHROPIC_AUTH_TOKEN"] ?? "dev-token",
},
},
{
feishu: async () => undefined,
provider: async () => undefined,
},
);
console.log("bootstrap ok:", JSON.stringify(result, null, 2));
} finally {
await prisma.$disconnect();
}
}
main().catch((error: unknown) => {
console.error("[dev-bootstrap] failed:", error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
*
* Surface for browsing and editing Organization-scoped Agent roles and the
* skills bound to them. Roles are org-owned data; the default-model picker is
* constrained to the env-default model registry (ADR-0017: there is no
* org-scoped model list — `OrganizationAgentRole.defaultModel` selects from the
* platform-enabled set). Skill *installation* is out of band (CLI / seed) per
* spec `Skill 管理是 org-scoped; 存储机制 OPEN`; this route only lists installed
* skills and binds them to roles.
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
import { createDefaultModelRegistry } from "../../settings/runtime.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export interface AgentConfigRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
}
export async function registerAgentConfigRoutes(
app: FastifyInstance,
config: AgentConfigRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
// skillStoreRoot is null: this surface does not install skills (see header).
const agentConfig = new OrganizationAgentConfiguration(config.prisma, null);
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const roles = await agentConfig.listRoles({ organizationId: auth.organization.id });
return { roles };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/agent-roles/:roleId", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
label?: unknown;
defaultModel?: unknown;
systemPrompt?: unknown;
tools?: unknown;
sortOrder?: unknown;
isDefault?: unknown;
};
if (typeof body.label !== "string" || body.label.trim() === "") {
return reply.status(400).send({
error: { code: "bad_request", message: "label is required" },
});
}
const role = await agentConfig.upsertRole({
organizationId: auth.organization.id,
roleId,
label: body.label,
...(body.defaultModel === null || typeof body.defaultModel === "string"
? { defaultModel: body.defaultModel as string | null }
: {}),
...(body.systemPrompt === null || typeof body.systemPrompt === "string"
? { systemPrompt: body.systemPrompt as string | null }
: {}),
...(body.tools === null || Array.isArray(body.tools)
? { tools: body.tools as readonly string[] | null }
: {}),
...(typeof body.sortOrder === "number" ? { sortOrder: body.sortOrder } : {}),
...(typeof body.isDefault === "boolean" ? { isDefault: body.isDefault } : {}),
});
return role;
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/agent-roles/:roleId/skills", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { skillNames?: unknown };
if (!Array.isArray(body.skillNames) || body.skillNames.some((n) => typeof n !== "string")) {
return reply.status(400).send({
error: { code: "bad_request", message: "skillNames must be a string array" },
});
}
await agentConfig.setRoleSkills({
organizationId: auth.organization.id,
roleId,
skillNames: body.skillNames as readonly string[],
});
return { skillNames: body.skillNames as string[] };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/agent-skills", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
return { skills };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const registry = createDefaultModelRegistry(process.env);
return { models: registry.listModels() };
} catch (err) {
return handleRouteError(reply, err);
}
});
}
+3 -1
View File
@@ -433,8 +433,10 @@ async function resolvePostLoginRedirect(
});
if (intended === null) return "/admin?error=not_an_active_org_member";
const orgRoot = `/admin/org/${intended.organization.slug}`;
// Default / missing returnTo sanitizes to "/admin". Always land in the org
// admin SPA (not the legacy static "close this tab" complete page).
if (returnTo === "/admin") {
return `/auth/feishu/complete?org=${encodeURIComponent(intended.organization.name)}`;
return orgRoot;
}
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
}
+1 -1
View File
@@ -155,7 +155,7 @@ export async function registerExplorerRoutes(
workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
});
return reply.status(201).send(result);
return reply.status(201).send({ id: result.projectId, name: body.name });
} catch (err) {
return handleRouteError(reply, err);
}
+5
View File
@@ -16,6 +16,7 @@ import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
import { registerTeamsRoutes } from "./teamsRoutes.js";
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
import { registerCapacityRoutes } from "./capacityRoutes.js";
import { registerAgentConfigRoutes } from "./agentConfigRoutes.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
@@ -110,6 +111,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerAgentConfigRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerSessionsAndUsageRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
+119 -4
View File
@@ -5,6 +5,33 @@ import { importSkillDirectory } from "./skillStore.js";
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export interface AgentRoleRow {
readonly id: string;
readonly roleId: string;
readonly label: string;
readonly defaultModel: string | null;
readonly systemPrompt: string | null;
readonly tools: readonly string[] | null;
readonly sortOrder: number;
readonly isDefault: boolean;
readonly disabledAt: string | null;
readonly createdAt: string;
readonly updatedAt: string;
readonly skillNames: readonly string[];
}
export interface AgentSkillRow {
readonly id: string;
readonly name: string;
readonly version: string;
readonly description: string | null;
readonly contentDigest: string;
readonly disabledAt: string | null;
readonly createdAt: string;
readonly updatedAt: string;
readonly boundRoleIds: readonly string[];
}
/**
* Deep module for controlled host-console Agent configuration. It owns the
* filesystem/DB ordering, Organization checks and role-skill composition so
@@ -13,14 +40,57 @@ const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export class OrganizationAgentConfiguration {
constructor(
private readonly prisma: PrismaClient,
private readonly skillStoreRoot: string,
private readonly skillStoreRoot: string | null,
) {}
async listRoles(input: { readonly organizationId: string }): Promise<readonly AgentRoleRow[]> {
await this.requireActiveOrganization(input.organizationId);
const roles = await this.prisma.organizationAgentRole.findMany({
where: { organizationId: input.organizationId, disabledAt: null },
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
select: { skill: { select: { name: true, disabledAt: true } } },
},
},
});
return roles.map((role) => toRoleRow(role));
}
async listSkills(input: { readonly organizationId: string }): Promise<readonly AgentSkillRow[]> {
await this.requireActiveOrganization(input.organizationId);
const skills = await this.prisma.organizationAgentSkill.findMany({
where: { organizationId: input.organizationId, disabledAt: null },
orderBy: [{ name: "asc" }],
include: {
roleBindings: {
where: { role: { disabledAt: null } },
select: { role: { select: { roleId: true } } },
},
},
});
return skills.map((skill) => ({
id: skill.id,
name: skill.name,
version: skill.version,
description: skill.description,
contentDigest: skill.contentDigest,
disabledAt: skill.disabledAt === null ? null : skill.disabledAt.toISOString(),
createdAt: skill.createdAt.toISOString(),
updatedAt: skill.updatedAt.toISOString(),
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
}));
}
async installSkill(input: {
readonly organizationId: string;
readonly sourceDir: string;
readonly version: string;
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
if (this.skillStoreRoot === null) {
throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
}
await this.requireActiveOrganization(input.organizationId);
const version = nonEmpty(input.version, "skill version");
const imported = await importSkillDirectory({
@@ -90,7 +160,7 @@ export class OrganizationAgentConfiguration {
readonly tools?: readonly string[] | null | undefined;
readonly sortOrder?: number | undefined;
readonly isDefault?: boolean | undefined;
}): Promise<{ readonly id: string; readonly roleId: string }> {
}): Promise<AgentRoleRow> {
await this.requireActiveOrganization(input.organizationId);
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
const label = nonEmpty(input.label, "role label");
@@ -150,7 +220,12 @@ export class OrganizationAgentConfiguration {
isDefault: effectiveIsDefault,
disabledAt: null,
},
select: { id: true, roleId: true },
include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
select: { skill: { select: { name: true, disabledAt: true } } },
},
},
});
const executionSurfaceChanged = previous !== null && (
(input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
@@ -182,7 +257,7 @@ export class OrganizationAgentConfiguration {
},
},
});
return role;
return toRoleRow(role);
});
}
@@ -295,3 +370,43 @@ function normalizeOptionalText(value: string | null | undefined): string | null
const normalized = value.trim();
return normalized === "" ? null : normalized;
}
function parseTools(value: Prisma.JsonValue): readonly string[] | null {
if (value === null) return null;
if (!Array.isArray(value)) return null;
return value.filter((t): t is string => typeof t === "string");
}
function toRoleRow(role: {
readonly id: string;
readonly roleId: string;
readonly label: string;
readonly defaultModel: string | null;
readonly systemPrompt: string | null;
readonly tools: Prisma.JsonValue;
readonly sortOrder: number;
readonly isDefault: boolean;
readonly disabledAt: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly skillBindings: ReadonlyArray<{
readonly skill: { readonly name: string; readonly disabledAt: Date | null };
}>;
}): AgentRoleRow {
return {
id: role.id,
roleId: role.roleId,
label: role.label,
defaultModel: role.defaultModel,
systemPrompt: role.systemPrompt,
tools: parseTools(role.tools),
sortOrder: role.sortOrder,
isDefault: role.isDefault,
disabledAt: role.disabledAt === null ? null : role.disabledAt.toISOString(),
createdAt: role.createdAt.toISOString(),
updatedAt: role.updatedAt.toISOString(),
skillNames: role.skillBindings
.filter((binding) => binding.skill.disabledAt === null)
.map((binding) => binding.skill.name),
};
}
+24
View File
@@ -1,3 +1,27 @@
/**
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
*
* Counts dynamic traffic only: APIs, auth, and other application handlers.
* Static SPA assets and the org-admin HTML shell are exempt so a single page
* load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
*/
/** Paths that must not consume the silo HTTP request-rate budget. */
export function isSiloHttpRateLimitExempt(url: string): boolean {
const path = (url.split("?", 1)[0] ?? url) || "/";
if (path === "/api/healthz") return true;
// SvelteKit build output and top-level static files (see admin/static.ts).
if (path === "/_app" || path.startsWith("/_app/")) return true;
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
// SPA index shell for client-side routes (not an API).
if (path === "/admin" || path.startsWith("/admin/")) return true;
return false;
}
export class SiloFixedWindowRateLimiter {
private windowStartedAt: number;
private used = 0;
+4 -2
View File
@@ -14,7 +14,7 @@ import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuAppl
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
import { readServerBinding } from "./settings/server.js";
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
import { SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
import { isSiloHttpRateLimitExempt, SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
function requireEnv(name: string): string {
const value = process.env[name];
@@ -42,7 +42,9 @@ export async function startHub(): Promise<void> {
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
app.addHook("onRequest", async (request, reply) => {
if (request.url === "/api/healthz") return;
// Static SPA assets / admin HTML shell / healthz do not count toward the
// silo requestRate budget (a full admin load can pull dozens of chunks).
if (isSiloHttpRateLimitExempt(request.url)) return;
const decision = requestLimiter.consume();
if (!decision.allowed) {
await reply
+1 -8
View File
@@ -346,14 +346,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
});
expect(defaultCallback.statusCode).toBe(302);
expect(defaultCallback.headers.location).toBe(
"/auth/feishu/complete?org=Test%20Default%20Organization",
);
const complete = await app.inject({ method: "GET", url: defaultCallback.headers.location! });
expect(complete.statusCode).toBe(200);
expect(complete.headers["content-type"]).toContain("text/html");
expect(complete.body).toContain("登录并加入组织成功");
expect(complete.body).toContain("Test Default Organization");
expect(defaultCallback.headers.location).toBe("/admin/org/test-default");
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
+25 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { SiloFixedWindowRateLimiter } from "../../src/deployment/siloRateLimit.js";
import {
isSiloHttpRateLimitExempt,
SiloFixedWindowRateLimiter,
} from "../../src/deployment/siloRateLimit.js";
describe("Silo fixed-window rate limiter", () => {
it("returns an explicit retry interval and resets at the next window", () => {
@@ -10,3 +13,24 @@ describe("Silo fixed-window rate limiter", () => {
expect(limiter.consume(61_000)).toEqual({ allowed: true });
});
});
describe("isSiloHttpRateLimitExempt", () => {
it("exempts healthz, SPA static assets, and the admin HTML shell", () => {
expect(isSiloHttpRateLimitExempt("/api/healthz")).toBe(true);
expect(isSiloHttpRateLimitExempt("/_app/version.json")).toBe(true);
expect(isSiloHttpRateLimitExempt("/_app/immutable/chunks/foo.js?v=1")).toBe(true);
expect(isSiloHttpRateLimitExempt("/favicon.ico")).toBe(true);
expect(isSiloHttpRateLimitExempt("/favicon.svg")).toBe(true);
expect(isSiloHttpRateLimitExempt("/robots.txt")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
});
it("still rate-limits APIs and auth", () => {
expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false);
expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false);
expect(isSiloHttpRateLimitExempt("/auth/feishu/x")).toBe(false);
expect(isSiloHttpRateLimitExempt("/auth/feishu/callback?code=1")).toBe(false);
expect(isSiloHttpRateLimitExempt("/")).toBe(false);
});
});