Compare commits

..

70 Commits

Author SHA1 Message Date
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
hongjr03 78f94fcc8c fix(ci): sync npm lockfiles so fleet deploy npm ci succeeds
Hub and admin-web package-lock.json were missing @emnapi/* entries that
npm 11 on the Alpha host requires, so deploy_fleet_release failed at
npm ci. Regenerate both lockfiles and retry incomplete release trees.
2026-07-15 00:18:22 +08:00
hongjr03 7269480abb ci: deploy Hub admin SPA fleets via Gitea Actions
Add deploy_fleet_release.sh and a workflow that rolls immutable Hub
releases (including admin-web) to educraft-dev on push/PR and educraft
on main/tags, selecting silos by HUB_PUBLIC_BASE_URL middle domain.
2026-07-15 00:14:22 +08:00
hongjr03 1d2f4657ba fix(org): match listMyProjects grants by principal pair
Filtering permission grants with separate principalType/principalId IN
lists matched cross-product rows (e.g. TEAM + user id). Use OR of exact
(type, id) pairs so members only see projects their principals hold.
2026-07-14 23:55:20 +08:00
hongjr03 4ad0259193 fix(admin): let project MANAGE holders list teams for grants
GET /teams was org-admin only while team-access mutations require project
MANAGE, so members with MANAGE saw an empty grant picker. Open the
read-only team list to any org member and load it in the project page
whenever the actor can manage the project.
2026-07-14 23:55:16 +08:00
hongjr03 4e2699d0a5 fix(admin): serve built SPA and include it in release builds
registerStaticSpa was never mounted, so production only exposed org-admin
APIs. Wire it after auth/API routes, fold admin:build into npm run build,
and install admin-web deps during deploy so admin-web/build ships with the
release for same-origin /admin/*.
2026-07-14 23:54:47 +08:00
ChickenPige0n 153d74d033 feat: 更新容量策略页面的维度标签和逻辑分组 2026-07-14 22:54:37 +08:00
ChickenPige0n 080efa70c5 feat(admin): gate project surfaces behind permission grants for members
The org admin SPA was org-admin only: every project route used
requireOrgRole, so a plain MEMBER could not reach the projects they held
a project grant on, and an org OWNER/ADMIN could mutate any project
without holding the project's `manage` grant. That contradicts ADR-0004
(spec `Permission.lean`): org role is not a project authorization root,
and the only out-of-role override is platform-admin force-release
(`RequiresAdmin`), not org admin.

Add `requireProjectPermission` (guards.ts): resolve any org member, bind
the project to their org, then check the PermissionGrant authorizer.
`allowOrgAdminOversight=true` lets OWNER/ADMIN through for *read*
oversight only; mutations pinned to `collaborator.manage`
(grant/revoke team-access) pass `allowOrgAdminOversight=false`, so an org
admin still needs the project MANAGE grant to mutate access. The project
detail GET now also returns `actorIsOrgAdmin` and `actorCanManageProject`
so the SPA can render mutation controls only for entitled actors.

Add a member-facing project surface:
- `GET /api/org/:orgSlug/my-projects` + `listMyProjects` resolve the
  actor's principals and return the projects with a READ+ grant.
- The SPA routes members (non-admin) to the projects page instead of the
  admin overview, renders a member project shell on project routes, shows
  a `我的项目` list for members and the full folder explorer for admins.
- The project detail page gates rename/archive/bind/sessions behind org
  admin and the grant/revoke UI behind `actorCanManageProject`.
- The denied panel now points members at their authorized projects.

Update admin-members-teams integration test: seed the owner with a MANAGE
grant on the test project so the org-owner flow still passes the new
project-level gate on team-access grant/revoke.
2026-07-14 21:25:18 +08:00
ChickenPige0n ab9dfad53a feat: org capacity policy admin surface
ADR-0022 / Spec.System.Capacity pins layered capacity limits: a platform
ceiling per dimension is unbreakable, and each organization may only set a
lower `organizationLimit`. The effective limit is the minimum of the two
(`LayeredLimit.effective`); dimensions with no org override fall back to
the platform ceiling. No dimension may be unlimited (a ceiling must exist
before an org limit can be set, `LayeredLimit.Valid`).

Add the backend: the pinned 23 CapacityDimension set + labels
(src/capacity/dimensions.ts), platform ceilings sourced from existing
runtime env vars plus `HUB_CEILING_<DIMENSION>` (src/capacity/ceilings.ts),
the OrganizationCapacityPolicy prisma model + migration, the
getCapacityPolicy/setCapacityPolicy service enforcing LayeredLimit.Valid,
and org-admin GET/PUT /api/org/:orgSlug/capacity-policy routes wired into
the org route tree.

Add the admin-web surface: CapacityDimension/CapacityPolicyView api client
types, capacityPolicy/setCapacityPolicy methods, a `容量` nav entry, and a
capacity page that lists every dimension with its platform ceiling (or
`未配置` when unset), an org-limit input (disabled until a ceiling exists),
and a live effective-value column. Saving sends the partial limits map;
the service rejects values above the ceiling or for unconfigured dimensions.
2026-07-14 21:23:41 +08:00
ChickenPige0n adce8fb6f5 chore: drop legacy PlatformRoleAssignment model
ADR-0023 / Spec.System.PlatformAdministration pins the platform
administrator as a separate identity/session/audit control plane,
intentionally not modeled in alpha (ADR-0025). The legacy
PlatformRoleAssignment / PlatformRole{ADMIN,TEACHER} table had no runtime
reader (no guard, route, or service queried it for an authorization
decision) and ADR-0023 requires it to be replaced before the platform
panel ships.

Drop the model, the PlatformRole enum, the User.platformRoles relation,
and the migration. Stop seeding platformRoles in externalSync principal
ingestion and the integration test helper. Update the doc comments on
OrganizationMembership and PermissionRole to point at the platform-admin
control plane instead of the dropped model.

The 20260709180000_organization_tenant_root backfill only referenced
PlatformRoleAssignment in a one-time INSERT...SELECT; no persistent
object references it, so dropping the table is safe after that migration.
2026-07-14 21:20:50 +08:00
ChickenPige0n b574ef871c refactor(org): keep archived-team grants as dead rows
Archiving a team no longer cascade-revokes its active TEAM->PROJECT grants
and memberships. The archived flag alone makes the team principal
unresolvable (permissions/principals.ts refuses archived teams), so the
dead grant/membership rows confer no access. listProjectTeamAccess now
filters archived teams out of the project view instead of relying on a
revokedAt cascade, and the org-admin teams page confirm copy is updated.
archiveTeam drops the revokedGrants count from its return shape.

ADR-0019 / Spec.System.Organization: principal resolution, not grant
mutation, is the access boundary for archived teams.
2026-07-14 21:17:20 +08:00
ChickenPige0n cbe569d7e6 style(admin-web): apply Prettier formatting
Run `prettier --write .` across all source files. Changes are purely
formatting: trailing commas, line wrapping at 120 chars, import
reordering, and CSS whitespace. No logic changes. Verified with
`prettier --check .` and `svelte-check` (0 errors, 0 warnings).
2026-07-14 19:19:13 +08:00
ChickenPige0n ae870a9b73 chore(admin-web): add Prettier formatter with Svelte support
Add prettier + prettier-plugin-svelte as devDependencies with a
.prettierrc.json matching the existing code style (tabs, single quotes,
semicolons, trailing commas, 120 char width). Add .prettierignore for
build artifacts and lockfile. Wire up `npm run format` (write) and
`npm run format:check` (CI gate) scripts.

Prettier is chosen over Biome because its prettier-plugin-svelte correctly
preserves <script> block indentation per Svelte convention; Biome's
experimental Svelte formatter flattens script-block indentation to column 0,
producing inconsistent output.
2026-07-14 19:19:13 +08:00
ChickenPige0n 18acc823c3 feat(admin-web): add Feishu Application Connection admin page
ADR-0021 pins the organization<->Feishu application binding to 1:1 and
the backend already exposes
  GET    /api/org/:orgSlug/feishu-application-connection
  PUT    /api/org/:orgSlug/feishu-application-connection  (rotate/create)
  DELETE /api/org/:orgSlug/feishu-application-connection  (disable)
backed by FeishuApplicationConnectionService with versioned envelopes
(ADR-0024). The org-admin SPA had no surface for it, so the only
connection type the spec requires was unmanageable from the admin UI.

Add a Feishu page that reads the current connection (status, redacted
app fingerprint, active version, updatedAt), rotates credentials with
appId/appSecret/botOpenId (+ optional verificationToken/encryptKey) as
the backend requires, and disables with confirmation. Add the matching
api client (feishuApplication / rotateFeishuApplication /
disableFeishuApplication), a feishu nav icon and a nav entry.
2026-07-14 19:13:29 +08:00
ChickenPige0n 8d2e0cb2c6 fix(admin-web): align provider surface with backend and ADR-0024
The org-admin provider page was a stale prototype wired to a removed
singular /provider-connection endpoint. It contradicted the pinned
invariants in several ways:

- It documented a process-env fallback for platform-managed credentials,
  but ADR-0024 / Spec.System.Organization pins the resolver fail-closed
  with no process-global key fallback.
- It exposed a BYOK<->PLATFORM_MANAGED mode toggle to org admins, but
  ADR-0021 makes platform-managed connections platform-admin owned; the
  org-side API (requireByokActor) rejects mutating them.
- It modeled one connection per org, while OrganizationProviderConnection
  is keyed by (org, providerId) and the backend exposes a list plus a
  per-providerId BYOK rotation.
- Its HTTP contract (/provider-connection, {baseUrl, hasAuthToken}) did
  not match the real backend (/provider-connections + /:providerId,
  {status, activeVersion, keyId}).
- It dangled a pointer to role/model pages that do not exist in the SPA;
  roles/skills are managed via the CLI.

Rewrite the page to list connections, show status/version/keyId, and
rotate BYOK credentials per providerId with baseUrl + authToken (+ optional
anthropicApiKey) as the backend requires. Platform-managed rows render
read-only. Drop the fallback copy and the dangling pointer. Replace the
singular ProviderConnection API client with providerConnections /
rotateProviderConnection and remove the now-unused PROVIDER_MODES constant.
2026-07-14 19:13:29 +08:00
ChickenPige0n 3ae0cc3e60 feat: update .gitignore, remove unused API interfaces, and add local dev scripts for bootstrap and seeding connections 2026-07-14 19:13:29 +08:00
ChickenPige0n b1ddf32238 chore: drop superseded admin-panel agent-config prototype after rebase onto main
main now ships the canonical org-scoped agent-config implementation
(OrganizationAgentRole/OrganizationAgentSkill + hub/src/agent/configuration.ts
+ hub/src/agent/skillStore.ts per ADR-0018, and envelope-encrypted
OrganizationProviderConnection per ADR-0024). The earlier admin-panel
prototype (OrgModel/OrgRole/simple ProviderConnection baseUrl+authToken,
modelRoutes.ts, agentConfig.ts, migration 20260710120000, admin-web
models/roles pages) is superseded and clashes with main's schema; drop it.

Follow-up still needed: rewire admin-web SPA to the new config APIs
(+layout.svelte nav still lists models/roles, RoleCard.svelte unused).
2026-07-14 19:13:28 +08:00
ChickenPige0n 9c33a4e9b9 feat: validate team slug format and improve error handling in team creation 2026-07-14 19:13:28 +08:00
ChickenPige0n acf7ae0cd7 style: update surface colors and typography for improved contrast and readability across various components
- Changed text colors from surface-400 to surface-600 and surface-500 to surface-700 for better visibility in multiple Svelte files.
- Updated background and border colors in app.css for a more cohesive industrial design.
- Adjusted font weights and sizes for headings, labels, and buttons to enhance clarity and user experience.
- Refined styles for tables, badges, and buttons to align with the new design language.
- Added new styles for input fields and switches to maintain consistency in the UI.
2026-07-14 19:13:27 +08:00
ChickenPige0n 0968545b5a refactor(admin-web): polish Chinese UI and bits-ui controls
Map roles to Chinese labels, remove ADR/spec wording from the surface, and replace native selects/checkboxes/modals with bits-ui Select, Checkbox, Switch, Dialog, Label, and Collapsible.
2026-07-14 19:13:27 +08:00
ChickenPige0n 552c1c353e feat: add org admin SPA for models, roles and provider
Introduce admin-web (Skeleton/SvelteKit), Prisma models for provider connection / OrgModel / OrgRole, DB-backed runtime settings, and admin API routes so org admins can manage agent configuration end-to-end.
2026-07-14 19:13:14 +08:00
sjfhsjfh 461d2e89b0 chore: Merge origin/main: hub v0.0.23-v0.0.25 into main with spec-rewrite 2026-07-14 15:44:21 +08:00
hongjr03 93f252b177 chore: release v0.0.25 2026-07-13 17:05:47 +08:00
hongjr03 2211beb42c fix: move folder creation into project move flow 2026-07-13 17:05:45 +08:00
hongjr03 2f0e0f2cd7 chore: release v0.0.24 2026-07-13 16:52:51 +08:00
hongjr03 69837bd50c feat: redesign Feishu project console 2026-07-13 16:52:45 +08:00
hongjr03 82f57317df feat: archive bindings on Feishu lifecycle events 2026-07-13 15:53:28 +08:00
hongjr03 d94cc787b2 Revert "fix: import legacy projects without wrapper folder"
This reverts commit 4f8df12fb0.
2026-07-13 15:28:59 +08:00
hongjr03 4f8df12fb0 fix: import legacy projects without wrapper folder 2026-07-13 15:27:57 +08:00
sjfhsjfh 3ebe4b754d refactor(spec): clean prose patterns across all modules
Remove filler/redundant patterns: 钉死/钉, 本模块, likec4 画不出/画得出,
臆造, 散文, 分歧点测试, 纯 plumbing, 恰好, 留白, 宪法第N条, 刻意.
No code definitions changed, only doc comments.
2026-07-13 11:26:23 +08:00
sjfhsjfh 3fa6a5a5a5 fix(spec): replace @Claude with @bot in Prelude and Run 2026-07-12 18:55:52 +08:00
sjfhsjfh be4260bcd0 refactor(spec): move AgentRole/Run/Memory/AgentSurface into System/Agent/ subdir 2026-07-12 18:43:11 +08:00
sjfhsjfh a4449f03c4 chore: ignore .env 2026-07-12 18:38:45 +08:00
sjfhsjfh 01bc20d25f feat(spec): add AgentRole, AgentSkill, RoleSkillBinding (ADR-0017/0018) 2026-07-12 18:38:17 +08:00
sjfhsjfh 38c3231190 refactor(spec): generalize Feishu to Connections
- Connections/Prelude.lean: ConnectionProvider 枚举 (当前仅飞书)
- Connections/Feishu.lean: FeishuAppBinding + FeishuProfile
- Connections.lean: ConnectionBinding/ConnectionProfile inductive
- Organization.feishu → connections: List ConnectionBinding
- User.feishu → connections: List ConnectionProfile
- 删除 FeishuConnection.lean
2026-07-12 15:54:16 +08:00
sjfhsjfh 63416e06ea refactor(spec): move FeishuProfile to FeishuConnection, clean docs
- FeishuProfile 从 User.lean 移到 FeishuConnection.lean
- User.lean 只留用户创建路径声明
- 清理所有 doc comment
2026-07-12 09:33:33 +08:00
sjfhsjfh 39bd2c9ff7 feat(spec): pin org-feishu app binding to 1:1
- FeishuConnection.lean: FeishuAppBinding (appId + appSecretEnvelope)
- Organization.feishu: Option FeishuAppBinding (Option 自带 1:1)
- 删除 FeishuConnectionId (不再需要游离类型)
- FeishuProfile 删除 connection 字段 (由 org 隐含)
2026-07-12 09:29:41 +08:00
sjfhsjfh e17e038232 feat(spec): add FeishuUserId to FeishuProfile
飞书 user_id 是租户内身份,换应用不变;open_id 是应用内身份,换应用即变。
两者都存:user_id 更稳定,open_id 是 API 调用句柄。
2026-07-12 09:26:13 +08:00
sjfhsjfh 678bc9f56c refactor(spec): tighten prose, replace jargon
- 角色格→角色体系 (3处)
- 租户根/tenant root→租户 (3处)
- 清理 Hierarchy/Organization/System 的 doc 注释
2026-07-12 09:23:39 +08:00
hongjr03 07aa10ef27 chore: release v0.0.23 2026-07-12 02:42:49 +08:00
hongjr03 816af1abdb feat: add paginated project discovery 2026-07-12 02:40:53 +08:00
sjfhsjfh 3a50ed0ce2 feat(spec): add three-tier subject hierarchy and org role lattice
- Hierarchy.lean: Platform/Organization/User struct, 三层主体层级
- User.lean: FeishuProfile, 飞书身份是绑定不是本体
- User struct: id/displayName/passwordHash/feishu
- Organization.lean: OrganizationRole(owner/admin/member) + 成员管理规则
- Prelude.lean: UserId/FeishuOpenId/FeishuConnectionId

实现偏离: spec 钉 User 为独立实体, 实现 User.id 由飞书身份派生

lake build 35/35 全绿
2026-07-12 00:21:13 +08:00
hongjr03 53d372e29b fix: report untracked legacy symlinks 2026-07-11 23:37:25 +08:00
hongjr03 530fcdd2b7 feat: migrate legacy projects through binding search 2026-07-11 23:33:23 +08:00
hongjr03 53998d2651 fix: enable proxy use in new silos 2026-07-11 15:07:28 +08:00
hongjr03 5b55cf18a8 Revert "fix: accept SDK provider capability headers"
This reverts commit e7ad5580ec.
2026-07-11 15:06:48 +08:00
hongjr03 b0d691d53f Revert "fix: pass provider capability as API key"
This reverts commit f065f9f978.
2026-07-11 15:06:48 +08:00
hongjr03 1f48c5b707 Revert "fix: provide capability for both SDK auth modes"
This reverts commit ebf870249f.
2026-07-11 15:06:48 +08:00
hongjr03 12a2f3117f Revert "fix: preserve run provider capability"
This reverts commit 63c86322de.
2026-07-11 15:06:48 +08:00
hongjr03 2ee84d9543 Revert "fix: carry provider capability in dedicated header"
This reverts commit 3087132083.
2026-07-11 15:06:47 +08:00
hongjr03 3087132083 fix: carry provider capability in dedicated header 2026-07-11 15:02:51 +08:00
hongjr03 63c86322de fix: preserve run provider capability 2026-07-11 15:00:34 +08:00
hongjr03 ebf870249f fix: provide capability for both SDK auth modes 2026-07-11 14:59:00 +08:00
hongjr03 f065f9f978 fix: pass provider capability as API key 2026-07-11 14:57:22 +08:00
hongjr03 e7ad5580ec fix: accept SDK provider capability headers 2026-07-11 14:55:29 +08:00
hongjr03 19d942e812 feat: automate managed silo provisioning 2026-07-11 14:53:03 +08:00
hongjr03 e5e923dd34 feat: add repeatable alpha silo setup 2026-07-11 14:30:50 +08:00
hongjr03 df0b12e38b fix: hide per-run cost from Feishu replies 2026-07-11 14:11:59 +08:00
hongjr03 83ec835d4c fix: show Feishu OAuth completion page 2026-07-11 14:07:43 +08:00
hongjr03 6ed56ddfc8 feat: auto-join scoped Feishu OAuth users 2026-07-11 14:00:43 +08:00
hongjr03 3bf643ff4d fix: guide Feishu users through onboarding 2026-07-11 13:52:19 +08:00
hongjr03 d36b00bbec feat: make agent roles and skills dynamic 2026-07-11 12:55:05 +08:00
hongjr03 17c0536958 fix: enable only curated agent skills 2026-07-11 12:25:52 +08:00
hongjr03 96e120e02c feat: add curated curriculum agent skills 2026-07-11 12:22:01 +08:00
186 changed files with 16465 additions and 1477 deletions
+126
View File
@@ -0,0 +1,126 @@
name: deploy admin (hub fleet)
# Rolls Hub releases that include the org-admin SPA (`admin-web` → registerStaticSpa)
# onto the managed Alpha host. Two fleets share one machine and are separated by
# the middle DNS label of each Silo's public URL:
#
# dev → https://<slug>.educraft-dev.paradigm-edu.net (every push + PR)
# prod → https://<slug>.educraft.paradigm-edu.net (main + tags)
#
# Example prod tenant:
# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100
#
# Required Gitea secret:
# DEPLOY_SSH_KEY — private key for root@39.107.254.4
#
# Optional secrets / vars (defaults match NEW_SILO_RUNBOOK):
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT, DEPLOY_BASE
# vars.ALLOW_EMPTY_FLEET=1 — green when the fleet has no silos yet
on:
push:
paths:
- "hub/**"
- ".gitea/workflows/deploy-admin.yml"
pull_request:
paths:
- "hub/**"
- ".gitea/workflows/deploy-admin.yml"
workflow_dispatch:
inputs:
fleet:
description: "Which fleet to deploy (dev | prod | both)"
required: true
default: both
allow_empty_fleet:
description: "Succeed if no silo matches (1/0)"
required: false
default: "0"
concurrency:
group: deploy-admin-host
cancel-in-progress: false
jobs:
deploy-dev:
name: deploy fleet dev (educraft-dev)
runs-on: ubuntu-latest
if: >
github.event_name == 'pull_request' ||
(github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch' &&
(github.event.inputs.fleet == 'dev' || github.event.inputs.fleet == 'both'))
steps:
- uses: actions/checkout@v5
- name: Install rsync + ssh
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
- name: Deploy Hub + admin SPA to educraft-dev fleet
env:
CPH_FLEET: dev
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
echo "missing secret DEPLOY_SSH_KEY" >&2
exit 1
fi
key="$(mktemp)"
trap 'rm -f "$key"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
chmod 600 "$key"
export PLATFORM_DEPLOY_SSH_KEY="$key"
# Apply managed-host defaults when secrets are unset.
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
bash hub/deploy/deploy_fleet_release.sh
deploy-prod:
name: deploy fleet prod (educraft)
runs-on: ubuntu-latest
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch' &&
(github.event.inputs.fleet == 'prod' || github.event.inputs.fleet == 'both'))
steps:
- uses: actions/checkout@v5
- name: Install rsync + ssh
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
- name: Deploy Hub + admin SPA to educraft fleet
env:
CPH_FLEET: prod
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
echo "missing secret DEPLOY_SSH_KEY" >&2
exit 1
fi
key="$(mktemp)"
trap 'rm -f "$key"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
chmod 600 "$key"
export PLATFORM_DEPLOY_SSH_KEY="$key"
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
bash hub/deploy/deploy_fleet_release.sh
+3
View File
@@ -11,6 +11,9 @@
# regenerable, not for VCS. The embedded engine mounts cph-render directly.
render/vendor/local-packages/
# Environment
.env
# Node (hub/ TS workspace and any future JS package)
node_modules/
+13
View File
@@ -28,6 +28,19 @@
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
控制面与 Docker adapter 后置(见 ADR-0025)。
- Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、
tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。
每个 Organization 必须且只能有一个启用中的默认 role;新建群绑定从该默认值初始化,之后
`ProjectGroupBinding` 持久化群内当前 role,run 在接纳时冻结该 role。飞书公开 slash 协议只含
`/project``/usage``/help`role、会话、目录操作走 `/project` 卡片,Claude 原生
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
系统 `Inbox` 只作为未分类项目的内部落点,不作为业务 folder 暴露;已绑定群通过
`@bot /project` 随时打开项目管理卡片,重命名仍走 org-scoped MANAGE 授权与审计。
## 纪律
@@ -34,8 +34,36 @@ provider runtime cursor needed to continue a conversation. For Claude Code SDK,
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call as
`options.resume`. Role is part of the session binding because role prompts and
tool surfaces can differ even when the underlying model is the same; `/draft`
and `/review` must not resume the same Claude runtime cursor by accident.
tool surfaces can differ even when the underlying model is the same. A Feishu
project group's active binding selects one Organization role for ordinary
messages. Switching that selection routes future messages to the selected
role's own session; it never mutates or merges provider cursors across roles.
Work freezes the selected role when accepted so queued requests cannot drift
after a later switch.
Role definitions are Organization-scoped runtime data. A role bundle selects
its default model, system prompt, tool allowlist and installed Agent skill
versions. PostgreSQL is authoritative for role composition and skill metadata;
skill bytes live in a content-addressed persistent store selected only by the
recorded SHA-256 digest. Updating a role or binding skills takes effect without
a Hub release or process restart. A change to the role's execution surface
(model, prompt, tools, selected skill content) archives its active sessions so
the next run cannot resume a provider context created under stale instructions;
label and ordering-only changes preserve conversational continuity.
Exactly one active role per Organization is the default used when a project
group is first bound. The default is configuration data, not a hard-coded role
name. Roles are selected through the Hub project console; role ids are not
public slash commands. A project participant may change the group's shared
selection only when both `agent.trigger` for the project and `role.trigger` for
the target role authorize that actor. The selection affects every participant's
future messages, while already accepted work keeps its frozen role.
Hub slash commands are a closed control-plane protocol. Unknown commands fail
explicitly and are never downgraded to Agent text. Claude SDK session commands
are invoked only through typed Hub actions. In particular, compaction resumes
the selected role session and sends the exact `/compact` prompt without
prepending Feishu context.
Environment variables:
```
@@ -122,6 +122,16 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
- `settingSources: []` and strict MCP configuration prevent an untrusted
workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths.
- Agent skills are Organization-scoped runtime configuration, not Hub release
assets. A controlled host-console installer imports each version into a
content-addressed persistent store and records its digest in PostgreSQL. A
role selects enabled Organization skills alongside its model, system prompt
and tool allowlist. Each run copies only those selected immutable versions
into a run-scoped plugin outside the project workspace; the sandbox exposes
that snapshot read-only and deletes it after the run. SDK-bundled skills and
filesystem setting sources remain disabled, so project `.claude` content
cannot register skills or widen tools. Requested skill versions are recorded
on `run.created`; SDK initialization remains authoritative loading evidence.
- Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+194 -54
View File
@@ -1,81 +1,221 @@
# para-26071100 飞书应用配置清单
# Educraft 组织接入与飞书应用配置指南
本文供 `para-26071100` 的飞书企业管理员操作。不要把 App Secret 粘贴到群聊、工单或本文档中;请通过约定的安全渠道交给平台部署人员
本文供准备接入 Educraft Alpha Silo 的学校、教培机构和组织管理员使用。完成本文后,请把末尾的“部署信息交付单”交给 Educraft 部署人员;我们会为组织创建独立的服务账号、数据库、运行目录和域名入口
## 1. 创建企业自建应用
> **安全提醒:** App Secret、模型 Provider Token 属于密钥,禁止粘贴到飞书群、普通云文档、工单正文或截图中。请只通过双方约定的安全渠道传递。
1. 打开飞书开放平台开发者后台。
2. 在目标企业下创建“企业自建应用”。
3. 应用名称可填写 `Educraft para-26071100`
4. 在“凭证与基础信息”记录:
- App ID(通常以 `cli_` 开头)
- App Secret
5. 添加并启用“机器人”能力。
## 1. 双方分别负责什么
Bot Open ID 不需要管理员手工寻找。平台部署人员会使用 App ID/App Secret 调用飞书 Bot Info API 获取,并在 bootstrap 时校验它确实属于这一个应用。
| 角色 | 负责事项 |
| --- | --- |
| 组织管理员 | 创建企业自建应用、启用机器人、开通最小权限、配置事件、回调和 OAuth 重定向 URL、发布应用、提供 OWNER 身份 |
| Educraft 部署人员 | 分配组织 slug 和域名、部署独立 Silo、加密保存应用及模型密钥、初始化 OWNER、联调和验收 |
| 试点 OWNER | 把机器人加入试点群、创建或绑定项目、组织首轮验收 |
## 2. 开通权限
## 2. 创建企业自建应用
在“权限管理”中搜索并申请下列能力。飞书控制台的中文名称可能随版本调整;如控制台同时显示 scope,可优先核对括号中的 scope
1. 打开[飞书开放平台开发者后台](https://open.feishu.cn/app)
2. 在目标企业下点击“创建企业自建应用”。应用名称建议填写“Educraft + 组织简称”。
3. 进入“凭证与基础信息”,记录 App ID 和 App Secret。
4. 进入“添加应用能力”,添加并启用“机器人”。
- 接收群聊中 @ 机器人的消息(`im:message.group_at_msg:readonly`
- 以应用身份发送消息(`im:message:send_as_bot`
- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`
- 获取与上传图片或文件资源(`im:resource`
- 添加、删除消息表情回复(`im:message.reactions:write_only`
- 获取用户基本信息(`contact:user.base:readonly``contact:user.basic_profile:readonly`
![凭证与基础信息页面;App Secret 默认以星号隐藏](assets/feishu-setup/01-credentials.png)
如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限
App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过安全渠道单独发送。Bot Open ID 不需要管理员手工查找;部署程序会用 App ID/App Secret 调用 Bot Info API 获取并校验归属
## 3. 配置事件与卡片回调
## 3. 开通最小权限
1. 进入“事件与回调”
2. 订阅方式选择“使用长连接接收事件”。
3. 添加事件 `im.message.receive_v1`(接收消息)。
4. 启用卡片交互回调 `card.action.trigger`,用于审批、中断运行和群聊建项目按钮。
5. 不需要填写公网 Event Callback URLHub 使用飞书长连接。
进入“权限管理”,点击“开通权限”,搜索并申请以下应用身份权限。控制台中文名称可能调整,请优先核对 scope
## 4. 配置 OAuth 回调
| 用途 | Scope |
| --- | --- |
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取与上传图片或文件 | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
| 通过手机号或邮箱查询 OWNER Open ID | `contact:user.id:readonly` |
域名 DNS 和 TLS 生效后,在安全设置/重定向 URL 中添加:
### 批量导入权限(推荐)
```text
https://para-26071100.educraft.paradigm-edu.net/auth/feishu/callback
在“权限管理”页面点击“批量处理 → 导入”,粘贴以下 JSON 后确认。导入只会新增本次列出的权限,不会删除或影响应用已经申请、开通的其他权限。
```json
{
"scopes": {
"tenant": [
"im:message.group_at_msg:readonly",
"im:message:send_as_bot",
"im:message:readonly",
"im:resource",
"im:message.reactions:write_only",
"contact:user.base:readonly",
"contact:user.basic_profile:readonly",
"contact:user.id:readonly"
],
"user": []
}
}
```
该回调用于 OWNER 登录受控的 Host Console。飞书群机器人长连接本身不依赖这个 URL
Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放在 `tenant`,不要为了省事把相同权限重复放进 `user`
## 5. 发布并安装应用
![权限管理入口与已开通权限列表](assets/feishu-setup/02-permissions.png)
1. 创建应用版本并提交企业管理员审核
2. 将应用可用范围至少包含试点 OWNER 和试点群成员。
3. 发布版本。
4. 将机器人加入准备试用的飞书群。
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限
## 6. 获取首位 OWNER 身份
## 4. 配置事件与卡片回调
平台 bootstrap 需要 OWNER 的飞书 Open ID 和显示名称。可通过飞书 API 调试台的用户信息接口查询;Open ID 通常以 `ou_` 开头。Union ID 可选,不影响首次部署
进入“事件与回调”
请把以下结果通过安全渠道交给平台部署人员:
1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。
2. 添加事件“接收消息” `im.message.receive_v1`
3. 添加事件“解散群” `im.chat.disbanded_v1`,用于立即归档该群的项目绑定。
4. 添加事件“机器人被移出群” `im.chat.member.bot.deleted_v1`,用于立即归档该群的项目绑定。
5. 在“回调配置”中同样选择长连接。
6. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。
![长连接与消息事件配置](assets/feishu-setup/03-events.png)
![卡片交互回调配置](assets/feishu-setup/04-callbacks.png)
这里不需要填写公网 Event Callback URL。Educraft Hub 使用飞书官方 SDK 的长连接模式。
## 5. 配置用户 OAuth 重定向 URL(必需)
普通群成员首次使用前,需要通过飞书 OAuth 建立其在本应用下的用户身份。进入“安全设置 → 重定向 URL”,添加组织专属 callback
```text
Organization: para-26071100
App ID: cli_...
App Secret: (安全渠道发送)
OWNER Open ID: ou_...
OWNER 显示名称:
OWNER Union ID: (可选)
试点群名称: (可选,便于验收)
https://<organization-slug>.educraft.paradigm-edu.net/auth/feishu/callback
```
## 7. 验收动作
例如组织 slug 为 `example-school`
平台通知部署完成后:
```text
https://example-school.educraft.paradigm-edu.net/auth/feishu/callback
```
1. OWNER 打开 Host Console,完成飞书 OAuth 登录。
2. 在试点群中 @机器人发送一条纯文本消息
3. 如果群尚未绑定项目,机器人应返回项目创建/绑定卡片。
4. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。
5. 再测试一个小文件附件,以及运行中断按钮。
![在安全设置中添加组织专属 OAuth 重定向 URL](assets/feishu-setup/05-security.png)
任何一步失败时,请保留发生时间、群名、消息截图和飞书 request/log ID;不要在截图中包含 App Secret 或 Provider token
必须使用 Educraft 部署人员最终确认的 slug;不要直接照抄示例。该 URL 用于 OAuth 返回并创建应用作用域下的飞书用户身份,不代表当前已经开放组织管理台
组织专属 OAuth 同时完成身份建立和入组:首次成功登录的用户会自动成为当前 Organization 的 `MEMBER`,回到群聊即可使用。`OWNER``ADMIN` 仍只能由部署人员或管理员显式授予;曾被移除的成员重新登录不会自动恢复资格。
## 6. 发布并安装应用
1. 进入“版本管理与发布”,点击“创建版本”。
2. 将应用可用范围至少覆盖试点 OWNER 和试点群成员。
3. 提交企业管理员审核并发布。
4. 发布成功后,将机器人加入准备试用的群。
![版本管理与发布页面](assets/feishu-setup/06-publish.png)
仅保存开发配置但未发布时,新增权限、事件和可用范围通常不会对试点用户生效。
## 7. 获取首位 OWNER 身份
首次部署必须指定一位组织 OWNER。OWNER 是 Educraft 组织内的初始管理员,不等同于飞书应用所有者;两者可以是同一个人,也可以不同。部署所需的 Open ID 必须由本次创建的企业自建应用查询,因为同一用户在不同应用下的 Open ID 不同,不能复用其他应用查到的值。
### 7.1 确认 OWNER
先确认哪一位企业成员将担任 OWNER。记录其飞书显示名称,并准备在飞书的成员选择器中按姓名找到本人。若企业内有同名成员,选择前须通过部门等信息核对身份。
### 7.2 开通查询权限和数据范围
确认应用已开通上文列出的用户基本信息和用户 ID 权限。如果使用上文的批量导入 JSON,这些权限已包含在内。
应用的通讯录数据范围还必须覆盖这位 OWNER。最小做法是把 OWNER 加入应用可用范围;不需要为此开放全企业通讯录。
### 7.3 在官方接口页面获取 Open ID
1. 打开飞书开放平台的[“获取单个用户信息”接口页面](https://open.feishu.cn/document/server-docs/contact-v3/user/get)。如果使用带 `appId` 参数的页面链接,可以直接进入对应应用;本文不提供固定 App ID,请在页面顶部选择本组织刚创建的企业自建应用,并核对 App ID 与交付单一致。
2. 找到路径参数 `user_id`,点击参数输入框旁的“获取”。
3. 在成员选择器中找到并选择 OWNER;如有同名成员,依据部门等信息确认本人。
4. ID 类型选择 `open_id`。将选择器返回的值填入 `user_id`,并保持查询参数 `user_id_type=open_id`
5. 以应用身份(`tenant_access_token`)调用接口,核对成功响应中 `data.user.name` 与 OWNER 本人一致。
6. 复制完整的 `data.user.open_id` 交给 Educraft 部署人员。Open ID 通常以 `ou_` 开头。
参数旁的“获取”是飞书文档调试台提供的成员选择功能,不是要求管理员预先知道 Open ID。不要复用其他应用查到的 Open ID;同一用户在不同应用下的 Open ID 不同。若无法选择成员或接口调用失败,依次检查:页面当前选择的 App ID、应用可用范围和通讯录数据范围是否覆盖 OWNER、用户基本信息与用户 ID 权限是否已开通并随应用版本发布。
### 7.4 核对并交付
交付前完成以下检查:
- 返回用户的姓名与 OWNER 本人一致;
- Open ID 来自本次组织的这一个 App ID;
- Open ID 完整复制,没有空格或省略号;
- 显示名称使用组织希望在 Educraft 中展示的姓名;
- Union ID 不是必填项,查不到可以留空。
最终向部署人员提供:
```text
OWNER Open IDou_...
OWNER 显示名称:
OWNER Union ID:(可选)
用于查询的 App IDcli_...
```
Open ID 和显示名称可以放在普通交付单中;不要把 App Secret 一起粘贴进去。
## 8. 部署信息交付单
请复制下面的模板填写。标注“安全渠道”的字段不要与普通字段放在同一条群消息或云文档中。
```text
【组织信息】
组织正式名称:
组织简称:
期望 organization slug:(小写字母、数字和连字符,例如 example-school
期望机器人显示名称:
【飞书应用】
App IDcli_...
App Secret:(通过安全渠道单独发送)
应用已发布:是 / 否
机器人能力已启用:是 / 否
消息事件(含解散群、机器人被移出群)和卡片回调已配置:是 / 否
OAuth 重定向 URL 已配置:是 / 否
【首位 OWNER】
OWNER Open IDou_...
OWNER 显示名称:
OWNER Union ID:(可选)
【试点范围】
试点群名称:(可选,用于验收定位)
初始 Team 名称:(可选;没有 Team 不影响首次部署)
预计试用人数:
【模型配置】
Provider 名称:(例如 OpenRouter
Provider Base URL
Provider Token:(通过安全渠道单独发送)
启用的模型 ID
```
Educraft 部署人员收到信息后,会回传最终 organization slug、访问域名、部署窗口和验收时间。若期望 slug 已被占用或不符合命名规则,会在部署前协调调整。
## 9. 上线验收
部署人员通知服务就绪后,由 OWNER 完成:
1. OWNER 在试点群中 @机器人发送一条纯文本消息
2. 如果群尚未绑定项目,确认机器人返回项目创建/绑定卡片。
3. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。
4. 选择一位非 OWNER 试点成员完成 OAuth 登录,确认其自动以 MEMBER 身份加入组织。
5. 该成员在同一群中 @机器人,确认能够进入已绑定项目。
6. 测试一个小文件附件、一次运行中断,以及一个需要生成文档的任务。
出现问题时,请保留发生时间、群名、消息截图和飞书 request/log ID。截图前确认其中不包含 App Secret、Provider Token 或其他密钥。
## 10. Alpha 阶段边界
- 每个组织运行在独立的系统用户、服务实例、数据库和持久化目录中。
- 组织的 role、system prompt、tools 和 skills 是运行时配置,不需要跟随版本发布。
- 同一项目同一时间只执行一个任务,避免并发修改同一个 workspace;组织级并发上限由部署配置决定。
- 当前由 Educraft 人工创建组织、OWNER、Provider Connection 和初始 Team,并通过服务器上的受控管理命令运维;组织管理台尚未开放。
- 非 OWNER 试点成员通过组织专属 OAuth 首次登录后自动成为 MEMBEROWNER/ADMIN 提权和被移除成员的恢复仍需人工操作。
- Alpha 不提供开放注册、自助密钥管理或跨组织资源共享。
+4
View File
@@ -4,3 +4,7 @@ dist/
.env
.env.*
!.env.example
.secrets/
admin-web/node_modules/
admin-web/build/
admin-web/.svelte-kit/
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+4
View File
@@ -0,0 +1,4 @@
build
.svelte-kit
node_modules
package-lock.json
+9
View File
@@ -0,0 +1,9 @@
{
"useTabs": true,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 120,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.2 create --template minimal --types ts --install npm D:/Projects/curriculum-project-hub/hub/admin-web
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+2641
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "admin-web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"bits-ui": "^2.18.1",
"prettier": "^3.9.5",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="zh-CN" data-theme="hamlindigo">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<meta name="description" content="Curriculum Project Hub — 组织管理后台" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
/* Fallback before CSS bundle: CJK-first industrial base */
html {
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', 'Inter', sans-serif;
}
</style>
<title>CPH Admin</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+396
View File
@@ -0,0 +1,396 @@
/**
* Thin API client for the org admin backend. Same-origin cookie auth.
*/
export class ApiError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status: number) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
}
}
async function request(method: string, url: string, body?: unknown): Promise<unknown> {
const init: RequestInit = {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
};
const res = await fetch(url, init);
const text = await res.text();
let data: unknown = null;
if (text !== '') {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error;
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
}
return data;
}
const get = (u: string) => request('GET', u);
const post = (u: string, b?: unknown) => request('POST', u, b);
const put = (u: string, b?: unknown) => request('PUT', u, b);
const patch = (u: string, b?: unknown) => request('PATCH', u, b);
const del = (u: string) => request('DELETE', u);
const orgBase = (slug: string) => `/api/org/${encodeURIComponent(slug)}`;
// --- Types ---
export interface OrgMembership {
id: string;
slug: string;
name: string;
status: string;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
}
export interface MeResponse {
user: {
id: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
};
organizations: OrgMembership[];
}
export interface OrgMember {
userId: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
createdAt: string;
}
export interface TeamRow {
id: string;
slug: string;
name: string;
description: string | null;
memberCount: number;
createdAt: string;
}
export interface TeamMemberRow {
userId: string;
feishuOpenId: string;
displayName: string;
createdAt: string;
}
export interface ExplorerFolder {
id: string;
name: string;
parentId: string | null;
sortKey: string;
projectCount: number;
childFolderCount: number;
}
export interface ExplorerProject {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string; createdAt: string } | null;
}
export interface ExplorerData {
folders: ExplorerFolder[];
projects: ExplorerProject[];
}
export interface ProjectDetail {
id: string;
name: string;
folderId: string | null;
folder: { id: string; name: string } | null;
workspaceDir: string;
createdAt: string;
archivedAt: string | null;
createdBy: { id: string; displayName: string; feishuOpenId: string } | null;
binding: { chatId: string; createdAt: string } | null;
actorIsOrgAdmin?: boolean;
actorCanManageProject?: boolean;
}
export interface TeamAccessEntry {
grantId: string;
projectId: string;
organizationId: string;
teamId: string;
teamSlug: string;
teamName: string;
role: 'READ' | 'EDIT' | 'MANAGE';
}
export interface SessionSummary {
id: string;
provider: string;
roleId: string;
model: string;
title: string | null;
runCount: number;
createdAt: string;
updatedAt: string;
}
export interface ProviderConnectionRow {
id: string;
providerId: string;
mode: 'BYOK' | 'PLATFORM_MANAGED';
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface FeishuApplicationConnection {
id: string;
appFingerprint: string;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface UsageTotals {
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
}
export interface ProjectUsageRow extends UsageTotals {
projectId: string;
projectName: string;
folderId: string | null;
}
export interface UsageReport {
from: string | null;
to: string | null;
projects: ProjectUsageRow[];
totals: UsageTotals;
}
export type CapacityDimension =
| 'requestRate'
| 'requestBodySize'
| 'agentConcurrency'
| 'admissionQueueLength'
| 'admissionQueueWait'
| 'fileSize'
| 'attachmentCount'
| 'archiveExpansion'
| 'projectStorage'
| 'organizationStorage'
| 'memberCount'
| 'projectCount'
| 'teamCount'
| 'folderCount'
| 'sessionCount'
| 'runWallTime'
| 'runTurns'
| 'runToolCalls'
| 'toolWallTime'
| 'runOutputSize'
| 'processMemory'
| 'processCpu'
| 'processCount';
export interface CapacityDimensionRow {
dimension: CapacityDimension;
platformCeiling: number | null;
organizationLimit: number | null;
effective: number | null;
}
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 = {
me: () => get('/api/me') as Promise<MeResponse>,
logout: () => post('/auth/logout'),
org: (slug: string) =>
get(orgBase(slug)) as Promise<{
organization: { id: string; slug: string; name: string; status: string };
actorRole: string;
}>,
settings: (slug: string) => get(`${orgBase(slug)}/settings`) as Promise<{ membersCanCreateProjects: boolean }>,
setSettings: (slug: string, body: { membersCanCreateProjects: boolean }) =>
patch(`${orgBase(slug)}/settings`, body) as Promise<{ membersCanCreateProjects: boolean }>,
members: (slug: string) => get(`${orgBase(slug)}/members`) as Promise<{ members: OrgMember[] }>,
addMember: (slug: string, body: { feishuOpenId: string; displayName?: string; role: string }) =>
post(`${orgBase(slug)}/members`, body) as Promise<OrgMember>,
setMemberRole: (slug: string, userId: string, role: string) => patch(`${orgBase(slug)}/members/${userId}`, { role }),
revokeMember: (slug: string, userId: string) => post(`${orgBase(slug)}/members/${userId}/revoke`),
teams: (slug: string) => get(`${orgBase(slug)}/teams`) as Promise<{ teams: TeamRow[] }>,
createTeam: (slug: string, body: { slug: string; name: string; description?: string }) =>
post(`${orgBase(slug)}/teams`, body) as Promise<TeamRow>,
updateTeam: (slug: string, teamId: string, body: { name?: string; description?: string | null }) =>
patch(`${orgBase(slug)}/teams/${teamId}`, body) as Promise<TeamRow>,
archiveTeam: (slug: string, teamId: string) => post(`${orgBase(slug)}/teams/${teamId}/archive`),
teamMembers: (slug: string, teamId: string) =>
get(`${orgBase(slug)}/teams/${teamId}/members`) as Promise<{ members: TeamMemberRow[] }>,
addTeamMember: (slug: string, teamId: string, body: { userId?: string; feishuOpenId?: string }) =>
post(`${orgBase(slug)}/teams/${teamId}/members`, body) as Promise<TeamMemberRow>,
revokeTeamMember: (slug: string, teamId: string, userId: string) =>
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[] }>,
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
post(`${orgBase(slug)}/folders`, body) as Promise<{
id: string;
name: string;
parentId: string | null;
sortKey: string;
}>,
renameFolder: (slug: string, folderId: string, body: { name?: string; sortKey?: string; parentId?: string | null }) =>
patch(`${orgBase(slug)}/folders/${folderId}`, body) as Promise<{
id: string;
name: string;
parentId: string | null;
sortKey: string;
}>,
archiveFolder: (slug: string, folderId: string) =>
post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>,
createProject: (slug: string, body: { name: string; folderId?: string }) =>
post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>,
project: (slug: string, projectId: string) => get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>,
renameProject: (slug: string, projectId: string, name: string) =>
patch(`${orgBase(slug)}/projects/${projectId}`, { name }),
moveProject: (slug: string, projectId: string, folderId: string | null) =>
patch(`${orgBase(slug)}/projects/${projectId}/folder`, { folderId }),
archiveProject: (slug: string, projectId: string) => post(`${orgBase(slug)}/projects/${projectId}/archive`),
archiveBinding: (slug: string, projectId: string) => post(`${orgBase(slug)}/projects/${projectId}/binding/archive`),
teamAccess: (slug: string, projectId: string) =>
get(`${orgBase(slug)}/projects/${projectId}/team-access`) as Promise<{ access: TeamAccessEntry[] }>,
grantTeamAccess: (slug: string, projectId: string, body: { teamId?: string; teamSlug?: string; role: string }) =>
put(`${orgBase(slug)}/projects/${projectId}/team-access`, body) as Promise<TeamAccessEntry>,
revokeTeamAccess: (slug: string, projectId: string, teamId: string) =>
del(`${orgBase(slug)}/projects/${projectId}/team-access/${teamId}`),
sessions: (slug: string, projectId: string, limit?: number) =>
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{
sessions: SessionSummary[];
}>,
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
if (params?.folderId) q.set('folderId', params.folderId);
const qs = q.toString();
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
},
providerConnections: (slug: string) =>
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
rotateProviderConnection: (
slug: string,
providerId: string,
body: { baseUrl: string; authToken: string; anthropicApiKey?: string },
) =>
put(
`${orgBase(slug)}/provider-connections/${encodeURIComponent(providerId)}`,
body,
) as Promise<ProviderConnectionRow>,
feishuApplication: (slug: string) =>
get(`${orgBase(slug)}/feishu-application-connection`) as Promise<{
connection: FeishuApplicationConnection | null;
}>,
rotateFeishuApplication: (
slug: string,
body: {
appId: string;
appSecret: string;
botOpenId: string;
verificationToken?: string;
encryptKey?: string;
},
) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise<FeishuApplicationConnection>,
disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
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[] }>,
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,32 @@
<script lang="ts">
import { Checkbox } from 'bits-ui';
import Icon from './Icon.svelte';
let {
checked = $bindable(false),
disabled = false,
class: className = '',
onchange,
}: {
checked?: boolean;
disabled?: boolean;
class?: string;
onchange?: (checked: boolean) => void;
} = $props();
</script>
<Checkbox.Root
class="saas-checkbox {className}"
{disabled}
{checked}
onCheckedChange={(next) => {
checked = next;
onchange?.(next);
}}
>
{#snippet children({ checked: isChecked })}
{#if isChecked}
<Icon name="check" class="h-3.5 w-3.5" />
{/if}
{/snippet}
</Checkbox.Root>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title = '暂无数据',
description,
action,
}: {
title?: string;
description?: string;
action?: Snippet;
} = $props();
</script>
<div class="saas-empty">
<div
class="mb-1 flex h-12 w-12 items-center justify-center border border-surface-300 bg-surface-100 text-surface-600"
>
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
</svg>
</div>
<p class="text-sm font-medium text-surface-900">{title}</p>
{#if description}
<p class="max-w-sm text-sm text-surface-600">{description}</p>
{/if}
{#if action}
<div class="mt-2">
{@render action()}
</div>
{/if}
</div>
@@ -0,0 +1,26 @@
<script lang="ts">
let {
message,
onretry,
}: {
message: string;
onretry?: () => void;
} = $props();
</script>
<div class="saas-card flex flex-wrap items-start gap-3 border-error-200 bg-error-50 p-4 text-error-700">
<svg class="mt-0.5 h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">请求失败</p>
<p class="mt-0.5 text-sm opacity-90">{message}</p>
</div>
{#if onretry}
<button type="button" class="saas-btn-ghost text-sm" onclick={onretry}>重试</button>
{/if}
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import type { ExplorerFolder } from '$lib/api';
import Icon from './Icon.svelte';
import FolderTree from './FolderTree.svelte';
let {
folder,
folders,
projects,
slug,
}: {
folder: ExplorerFolder;
folders: ExplorerFolder[];
projects: {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string } | null;
}[];
slug: string;
} = $props();
let open = $state(true);
</script>
<div>
<button
type="button"
class="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm transition hover:bg-surface-100"
onclick={() => (open = !open)}
>
<span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span>
<span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800">
<Icon name="folder" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span>
<span class="saas-badge-neutral">{folder.projectCount} 项目</span>
{#if folder.childFolderCount > 0}
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
{/if}
</button>
{#if open}
<div class="ml-4 border-l border-surface-300 pl-2">
<FolderTree {folders} {projects} parentId={folder.id} {slug} />
</div>
{/if}
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { ExplorerFolder } from '$lib/api';
import { fmtDate } from '$lib/format';
import Icon from './Icon.svelte';
import FolderNode from './FolderNode.svelte';
let {
folders,
projects,
parentId,
slug,
}: {
folders: ExplorerFolder[];
projects: {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string } | null;
}[];
parentId: string | null;
slug: string;
} = $props();
let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
let childProjects = $derived(projects.filter((p) => p.folderId === parentId));
</script>
<div class="space-y-0.5">
{#each childProjects as p (p.id)}
<a
href={`/admin/org/${slug}/projects/${p.id}`}
class="flex items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100"
>
<span class="flex h-7 w-7 items-center justify-center border border-primary-200 bg-primary-50 text-primary-700">
<Icon name="file" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{p.name}</span>
{#if p.binding}
<span class="saas-badge-success">已绑定</span>
{/if}
<span class="hidden text-xs text-surface-600 sm:inline">{fmtDate(p.createdAt)}</span>
</a>
{/each}
{#each childFolders as f (f.id)}
<FolderNode folder={f} {folders} {projects} {slug} />
{/each}
</div>
@@ -0,0 +1,137 @@
<script lang="ts">
/** Inline nav icons for the admin shell. */
let {
name,
class: className = 'h-4 w-4',
}: {
name:
| 'overview'
| 'members'
| 'teams'
| 'projects'
| 'provider'
| 'feishu'
| 'menu'
| 'logout'
| 'org'
| 'chevron'
| 'arrow-left'
| 'folder'
| 'file'
| 'check'
| 'roles';
class?: string;
} = $props();
</script>
{#if name === 'overview'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 12l9-9 9 9M5 10v9a1 1 0 001 1h3v-5h6v5h3a1 1 0 001-1v-9"
/>
</svg>
{:else if name === 'members'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 7.5a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.5 19.5a7.5 7.5 0 0115 0"
/>
</svg>
{:else if name === 'teams'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.09 9.09 0 003.74-.72 9 9 0 00-5.07-5.95M15 11a4 4 0 10-8 0 4 4 0 008 0zM4.26 18a9 9 0 0115.48 0"
/>
</svg>
{:else if name === 'projects'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
/>
</svg>
{:else if name === 'provider'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.19 8.688a4.5 4.5 0 016.364 6.364l-3.182 3.182a4.5 4.5 0 01-6.364-6.364"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.81 15.312a4.5 4.5 0 01-6.364-6.364l3.182-3.182a4.5 4.5 0 016.364 6.364"
/>
</svg>
{:else if name === 'feishu'}
<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 8.25h15a8.25 8.25 0 01-8.25 8.25A8.25 8.25 0 014.5 8.25z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 11.25h.01M12 11.25h.01M15.75 11.25h.01" />
</svg>
{:else if name === 'menu'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
{:else if name === 'logout'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6A2.25 2.25 0 005.25 5.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l3 3m0 0l-3 3m3-3H6"
/>
</svg>
{:else if name === 'org'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
/>
</svg>
{:else if name === 'chevron'}
<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
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
/>
</svg>
{:else if name === 'file'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{:else if name === 'check'}
<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,15 @@
<script lang="ts">
let { label = '加载中…' }: { label?: string } = $props();
</script>
<div class="flex flex-col items-center justify-center gap-3 py-16 text-surface-600">
<svg class="h-7 w-7 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">{label}</p>
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Dialog } from 'bits-ui';
let {
open = $bindable(false),
title,
children,
onclose,
}: {
open?: boolean;
title: string;
children: Snippet;
onclose?: () => void;
} = $props();
</script>
<Dialog.Root
bind:open
onOpenChange={(next) => {
if (!next) onclose?.();
}}
>
<Dialog.Portal>
<Dialog.Overlay class="saas-modal-backdrop" />
<Dialog.Content class="saas-modal">
<div class="mb-4 flex items-start justify-between gap-3">
<Dialog.Title class="text-lg font-semibold text-surface-900">{title}</Dialog.Title>
<Dialog.Close class="saas-btn-ghost px-2! py-1! text-surface-600" aria-label="关闭">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</Dialog.Close>
</div>
{@render children()}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: Snippet;
} = $props();
</script>
<div class="saas-toolbar">
<div class="min-w-0">
<h1 class="saas-page-title">{title}</h1>
{#if description}
<p class="saas-muted mt-1">{description}</p>
{/if}
</div>
{#if actions}
<div class="ml-auto flex flex-wrap items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
@@ -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>
@@ -0,0 +1,66 @@
<script lang="ts">
import { Select } from 'bits-ui';
import Icon from './Icon.svelte';
export type SelectItem = { label: string; value: string; disabled?: boolean };
let {
items,
value = $bindable(''),
class: className = '',
disabled = false,
placeholder = '请选择…',
onchange,
}: {
items: SelectItem[];
value?: string;
class?: string;
disabled?: boolean;
placeholder?: string;
onchange?: (value: string) => void;
} = $props();
</script>
<Select.Root
type="single"
{items}
{disabled}
{value}
onValueChange={(next) => {
value = next;
onchange?.(next);
}}
>
<Select.Trigger class="saas-select-trigger {className}" {disabled}>
<span class="min-w-0 flex-1 truncate text-left">
<Select.Value {placeholder} />
</span>
<svg
class="ml-2 h-4 w-4 shrink-0 text-surface-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15l3.75 3.75L15.75 15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9l3.75-3.75L15.75 9" />
</svg>
</Select.Trigger>
<Select.Portal>
<Select.Content class="saas-select-content" sideOffset={6} collisionPadding={8}>
<Select.Viewport class="p-1">
{#each items as item (item.value)}
<Select.Item class="saas-select-item" value={item.value} label={item.label} disabled={item.disabled}>
{#snippet children({ selected })}
<span class="min-w-0 flex-1 truncate">{item.label}</span>
{#if selected}
<Icon name="check" class="ml-2 h-4 w-4 shrink-0 text-primary-600" />
{/if}
{/snippet}
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
@@ -0,0 +1,19 @@
<script lang="ts">
let {
label,
value,
hint,
}: {
label: string;
value: string;
hint?: string;
} = $props();
</script>
<div class="saas-stat">
<div class="saas-stat-label">{label}</div>
<div class="saas-stat-value">{value}</div>
{#if hint}
<div class="saas-help">{hint}</div>
{/if}
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Switch } from 'bits-ui';
let {
checked = $bindable(false),
disabled = false,
class: className = '',
onchange,
}: {
checked?: boolean;
disabled?: boolean;
class?: string;
onchange?: (checked: boolean) => void;
} = $props();
</script>
<Switch.Root
class="saas-switch {className}"
{disabled}
{checked}
onCheckedChange={(next) => {
checked = next;
onchange?.(next);
}}
>
<Switch.Thumb class="saas-switch-thumb" />
</Switch.Root>
@@ -0,0 +1,25 @@
<script lang="ts">
import { dismissToast, toasts } from '$lib/toast';
const kindClass: Record<string, string> = {
info: 'border-surface-200 bg-surface-50 text-surface-800',
success: 'border-success-200 bg-success-50 text-success-800',
error: 'border-error-200 bg-error-50 text-error-800',
};
</script>
<div class="pointer-events-none fixed inset-x-0 top-0 z-100 flex flex-col items-end gap-2 p-4">
{#each $toasts as t (t.id)}
<div
class="pointer-events-auto flex max-w-sm items-start gap-3 border px-4 py-3 text-sm shadow-[4px_4px_0_rgb(15_23_42/0.12)] {kindClass[
t.kind
] ?? kindClass.info}"
role="status"
>
<p class="min-w-0 flex-1">{t.message}</p>
<button type="button" class="opacity-60 hover:opacity-100" onclick={() => dismissToast(t.id)} aria-label="关闭">
×
</button>
</div>
{/each}
</div>
+39
View File
@@ -0,0 +1,39 @@
export interface ToolOption {
id: string;
label: string;
group: string;
}
export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'read_file', label: '读取文件', group: '文件' },
{ id: 'write_file', label: '写入文件', group: '文件' },
{ id: 'list_files', label: '列目录', group: '文件' },
{ id: 'search_files', label: '搜索', group: '文件' },
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
{ id: 'request_approval', label: '请求审批', group: '飞书' },
];
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */
export const ORG_ROLES = ['OWNER', 'ADMIN', 'MEMBER'] as const;
export type OrgRole = (typeof ORG_ROLES)[number];
export const ORG_ROLE_LABELS: Record<OrgRole, string> = {
OWNER: '所有者',
ADMIN: '管理员',
MEMBER: '成员',
};
/** 项目团队授权角色(接口枚举保持英文,界面用 permissionRoleLabel */
export const PERMISSION_ROLES = ['READ', 'EDIT', 'MANAGE'] as const;
export type PermissionRole = (typeof PERMISSION_ROLES)[number];
export const PERMISSION_ROLE_LABELS: Record<PermissionRole, string> = {
READ: '只读',
EDIT: '编辑',
MANAGE: '管理',
};
+46
View File
@@ -0,0 +1,46 @@
import { ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS, type OrgRole, type PermissionRole } from './constants';
export function fmtDate(iso: string): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function fmtDateOnly(iso: string): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
}
export function fmtCost(usd: number | null): string {
if (usd === null) return '—';
return `$${Number(usd).toFixed(4)}`;
}
export function fmtNum(n: number): string {
return n.toLocaleString();
}
export function orgRoleLabel(role: string): string {
const key = role.toUpperCase() as OrgRole;
return ORG_ROLE_LABELS[key] ?? role;
}
export function permissionRoleLabel(role: string): string {
const key = role.toUpperCase() as PermissionRole;
return PERMISSION_ROLE_LABELS[key] ?? role;
}
export function providerModeLabel(mode: string): string {
if (mode === 'BYOK') return '自带密钥';
if (mode === 'PLATFORM_MANAGED') return '平台托管';
return mode;
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+71
View File
@@ -0,0 +1,71 @@
import { writable } from 'svelte/store';
import { api, type MeResponse } from './api';
interface SessionState {
loading: boolean;
me: MeResponse | null;
error: string | null;
}
export const session = writable<SessionState>({
loading: true,
me: null,
error: null,
});
export async function loadSession(): Promise<void> {
session.update((s) => ({ ...s, loading: true, error: null }));
try {
const me = await api.me();
session.set({ loading: false, me, error: null });
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 401) {
redirectToLogin();
return;
}
session.set({
loading: false,
me: null,
error: err instanceof Error ? err.message : String(err),
});
}
}
/**
* 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 {
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> {
await api.logout();
redirectToLogin();
}
+34
View File
@@ -0,0 +1,34 @@
import { writable } from 'svelte/store';
export type ToastKind = 'info' | 'success' | 'error';
export interface ToastItem {
id: number;
message: string;
kind: ToastKind;
}
let seq = 0;
export const toasts = writable<ToastItem[]>([]);
export function pushToast(message: string, kind: ToastKind = 'info', ms = 3200): void {
const id = ++seq;
toasts.update((list) => [...list, { id, message, kind }]);
if (ms > 0) {
setTimeout(() => {
toasts.update((list) => list.filter((t) => t.id !== id));
}, ms);
}
}
export function dismissToast(id: number): void {
toasts.update((list) => list.filter((t) => t.id !== id));
}
export function toastSuccess(message: string): void {
pushToast(message, 'success');
}
export function toastError(message: string): void {
pushToast(message, 'error', 5000);
}
+386
View File
@@ -0,0 +1,386 @@
<script lang="ts">
import '../routes/app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api';
import { orgRoleLabel } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
import SelectField from '$lib/components/SelectField.svelte';
let { children } = $props();
let mobileNavOpen = $state(false);
let redirecting = $state(false);
onMount(() => {
loadSession();
});
const navItems = [
{ key: 'overview', label: '概览', icon: 'overview' as const },
{ key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ 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 },
];
function isAdmin(org: OrgMembership): boolean {
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
function orgSlugFromPath(): string | null {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
return decodeURIComponent(parts[2]);
}
return null;
}
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects';
}
function memberships(): OrgMembership[] {
return $session.me?.organizations ?? [];
}
function adminOrgs(): OrgMembership[] {
return memberships().filter(isAdmin);
}
function currentOrg(): OrgMembership | null {
const slug = orgSlugFromPath();
if (!slug) return null;
return memberships().find((o) => o.slug === slug) ?? null;
}
function pickHomeOrg(): OrgMembership | null {
const admin = adminOrgs()[0];
if (admin) return admin;
return memberships()[0] ?? null;
}
function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return '';
return parts[3] ?? 'overview';
}
function navHref(key: string): string {
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
if (!slug) return '/';
if (key === 'overview') return `/admin/org/${slug}`;
return `/admin/org/${slug}/${key}`;
}
function pageTitle(): string {
const key = activeKey();
if (key === 'overview' || key === '') return '概览';
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
}
function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
void goto(`/admin/org/${nextSlug}`);
}
function handleLogout(e: Event) {
e.preventDefault();
logout();
}
function orgSelectItems(list: OrgMembership[]) {
return list.map((o) => ({
value: o.slug,
label: `${o.name} · ${orgRoleLabel(o.role)}`,
}));
}
$effect(() => {
page.url.pathname;
mobileNavOpen = false;
});
$effect(() => {
if ($session.loading || !$session.me) return;
const slug = orgSlugFromPath();
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
// Org admins: route to their first admin org if none matched as admin.
const admins = adminOrgs();
if (matched && isAdmin(matched)) {
redirecting = false;
return;
}
if (!matched && admins.length > 0) {
const target = `/admin/org/${admins[0].slug}`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
return;
}
// Members (non-admin): project pages are open to project MANAGE holders;
// the org overview and other admin-only surfaces are not for them.
if (matched && !isAdmin(matched)) {
redirecting = false;
const parts = page.url.pathname.split('/').filter(Boolean);
const onOverview = parts.length === 3; // /admin/org/:slug
if (onOverview) {
const target = `/admin/org/${matched.slug}/projects`;
if (page.url.pathname !== target) {
redirecting = true;
void goto(target, { replaceState: true });
}
}
return;
}
// No matched org and no admin orgs: route a member to their first org's
// projects page so they can reach project MANAGE surfaces.
if (!matched && memberships().length > 0) {
const home = memberships()[0];
const target = `/admin/org/${home.slug}/projects`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
}
});
</script>
<ToastHost />
{#if $session.loading || redirecting}
<div class="saas-status-panel">
<div class="flex flex-col items-center gap-3 text-surface-600">
<svg class="h-8 w-8 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p>
</div>
</div>
{:else if $session.error}
<div class="saas-status-panel">
<div class="saas-status-card">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-error-300 bg-error-100 text-error-700 font-bold"
>
!
</div>
<h2 class="mb-1 text-lg font-semibold">无法连接到后端</h2>
<p class="mb-5 text-sm text-surface-700">{$session.error}</p>
<button class="saas-btn-primary" onclick={() => loadSession()}>重试</button>
</div>
</div>
{:else if !$session.me}
<div class="saas-status-panel">
<div class="saas-status-card">
<div
class="mx-auto mb-5 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-white font-bold"
>
CPH
</div>
<h1 class="text-xl font-semibold">Curriculum Project Hub</h1>
<p class="mt-2 mb-6 text-sm text-surface-700">登录以管理组织、项目、团队与模型供应。</p>
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
</div>
</div>
{:else if currentOrg() && isAdmin(currentOrg()!)}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
{#if mobileNavOpen}
<button
type="button"
class="fixed inset-0 z-40 bg-surface-950/40 md:hidden"
aria-label="关闭导航"
onclick={() => (mobileNavOpen = false)}
></button>
{/if}
<aside
class="saas-sidebar fixed inset-y-0 left-0 z-50 transition-transform md:static md:translate-x-0
{mobileNavOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}"
>
<div class="flex items-center gap-2.5 px-4 py-4">
<div
class="flex h-9 w-9 items-center justify-center border border-primary-700 bg-primary-600 text-xs font-bold tracking-wide text-white"
>
CPH
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
<div class="truncate text-xs text-surface-600">Curriculum Hub</div>
</div>
</div>
<div class="px-3 pb-3">
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
<Icon name="org" class="h-3.5 w-3.5" />
组织
</div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
</div>
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-600">工作台</p>
{#each navItems as item}
{@const active =
activeKey() === item.key || (item.key === 'overview' && (activeKey() === '' || activeKey() === 'overview'))}
<a href={navHref(item.key)} class="saas-nav-item" data-active={active ? 'true' : 'false'}>
<Icon name={item.icon} class="h-4 w-4 shrink-0 opacity-80" />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="border-t border-surface-300 p-3">
<div class="flex items-center gap-2.5 border border-surface-300 bg-surface-100 px-2.5 py-2">
{#if me.user.avatarUrl}
<img src={me.user.avatarUrl} alt="" class="h-8 w-8 object-cover" />
{:else}
<div
class="flex h-8 w-8 items-center justify-center border border-primary-300 bg-primary-100 text-xs font-semibold text-primary-800"
>
{me.user.displayName.slice(0, 1)}
</div>
{/if}
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-surface-900">{me.user.displayName}</div>
<div class="truncate text-[11px] text-surface-600">{orgRoleLabel(org.role)}</div>
</div>
<button
type="button"
class="p-1.5 text-surface-600 transition hover:bg-surface-200 hover:text-error-700"
title="退出登录"
onclick={handleLogout}
>
<Icon name="logout" class="h-4 w-4" />
</button>
</div>
</div>
</aside>
<div class="saas-main">
<header class="saas-topbar">
<button
type="button"
class="saas-btn-ghost px-2! md:hidden"
onclick={() => (mobileNavOpen = !mobileNavOpen)}
aria-label="打开导航"
>
<Icon name="menu" class="h-5 w-5" />
</button>
<div class="min-w-0">
<div class="flex items-center gap-1.5 text-xs text-surface-600">
<span class="truncate">{org.name}</span>
<span>/</span>
<span class="truncate font-medium text-surface-900">{pageTitle()}</span>
</div>
</div>
<div class="ml-auto hidden items-center gap-2 sm:flex">
<span class="saas-badge-primary">{org.status}</span>
<span class="saas-badge-neutral font-mono">/{org.slug}</span>
</div>
</header>
<main class="saas-content">
<div class="saas-content-inner">
{@render children()}
</div>
</main>
</div>
</div>
{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()}
{@const org = currentOrg()!}
{@const me = $session.me!}
<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="返回项目列表">
<Icon name="menu" class="h-5 w-5" />
</a>
<div class="min-w-0">
<div class="flex items-center gap-1.5 text-xs text-surface-600">
<span class="truncate">{org.name}</span>
<span>/</span>
<span class="truncate font-medium text-surface-900">项目</span>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<span class="saas-badge-neutral font-mono">/{org.slug}</span>
<button
type="button"
class="p-1.5 text-surface-600 transition hover:bg-surface-200 hover:text-error-700"
title="退出登录"
onclick={handleLogout}
>
<Icon name="logout" class="h-4 w-4" />
</button>
</div>
</header>
<main class="saas-content">
<div class="saas-content-inner">
{@render children()}
</div>
</main>
</div>
</div>
{:else if currentOrg() && !isAdmin(currentOrg()!)}
{@const denied = currentOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
<p class="mb-3 text-sm text-surface-700">
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
</p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>查看我的项目</a>
{#if memberships().length > 1}
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
<div class="mb-4">
<SelectField items={orgSelectItems(memberships())} value={denied.slug} onchange={switchOrg} />
</div>
{/if}
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div>
</div>
{:else if memberships().length > 0}
{@const denied = pickHomeOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-700">
即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。
</p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>立即进入</a>
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div>
</div>
{:else}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">未加入组织</h2>
<p class="mb-3 text-sm text-surface-700">飞书账号已登录,但当前账号尚未加入任何组织。</p>
<p class="mb-5 text-left text-xs text-surface-600">
open_id
<code class="break-all font-mono text-surface-600">{$session.me!.user.feishuOpenId}</code>
</p>
<button class="saas-btn-primary" onclick={handleLogout}>退出登录</button>
</div>
</div>
{/if}
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { session, redirectToLogin } from '$lib/session';
function homeForSession(): string | null {
const me = $session.me;
if (!me) return null;
const admin = me.organizations.find((o) => {
const r = String(o.role ?? '').toUpperCase();
return r === 'OWNER' || r === 'ADMIN';
});
const target = admin ?? me.organizations[0];
return target ? `/admin/org/${target.slug}` : null;
}
onMount(() => {
const unsub = session.subscribe((s) => {
if (s.loading) return;
if (!s.me) {
redirectToLogin();
return;
}
const dest = homeForSession();
if (dest) void goto(dest, { replaceState: true });
});
return unsub;
});
</script>
<div class="saas-status-panel">
<div class="flex flex-col items-center gap-3 text-surface-600">
<svg class="h-7 w-7 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">正在进入工作台…</p>
</div>
</div>
@@ -0,0 +1,147 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import SwitchControl from '$lib/components/SwitchControl.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let orgSlug = $derived(page.params.slug ?? '');
let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined);
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
let usage = $state<Awaited<ReturnType<typeof api.usage>> | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let saving = $state(false);
async function load() {
loading = true;
error = null;
try {
[settings, usage] = await Promise.all([api.settings(orgSlug), api.usage(orgSlug)]);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function setMembersCanCreate(next: boolean) {
if (!settings || settings.membersCanCreateProjects === next) return;
saving = true;
const prev = settings.membersCanCreateProjects;
settings.membersCanCreateProjects = next;
try {
await api.setSettings(orgSlug, { membersCanCreateProjects: next });
toastSuccess(next ? '已允许成员自助建项' : '已关闭成员自助建项');
} catch (err) {
settings.membersCanCreateProjects = prev;
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (orgSlug) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if org && settings && usage}
<PageHeader title={org.name} description="组织健康度、用量与生产策略一览。" />
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="saas-card-pad sm:col-span-2 lg:col-span-1">
<p class="saas-section-title mb-3">组织信息</p>
<dl class="space-y-2.5 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">Slug</dt>
<dd class="font-mono text-xs text-surface-800">/{org.slug}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">状态</dt>
<dd><span class="saas-badge-success">{org.status}</span></dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">你的角色</dt>
<dd><span class="saas-badge-primary">{orgRoleLabel(org.role)}</span></dd>
</div>
</dl>
</div>
<div class="saas-card-pad sm:col-span-2">
<p class="saas-section-title mb-1">项目自助创建策略</p>
<p class="saas-muted mb-4">开启后,普通老师可在飞书群自助创建项目;关闭后仅所有者与管理员可建。</p>
<div class="flex items-center gap-3 border border-surface-300 bg-surface-100 px-4 py-3">
<SwitchControl checked={settings.membersCanCreateProjects} disabled={saving} onchange={setMembersCanCreate} />
<div>
<div class="text-sm font-medium text-surface-900">允许成员自助创建项目</div>
<div class="text-xs text-surface-600">普通成员在飞书群中自助建项</div>
</div>
</div>
</div>
</div>
<div class="mb-4 flex items-end justify-between gap-3">
<div>
<h2 class="saas-section-title">用量概览</h2>
<p class="saas-muted">全组织智能体运行汇总</p>
</div>
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
</div>
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
</div>
{#if usage.projects.length === 0}
<div class="saas-empty">
<p class="text-sm text-surface-600">暂无项目用量数据</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>运行</th>
<th>in / out tokens</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each usage.projects as p}
<tr>
<td class="font-medium">{p.projectName}</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{:else}
<div class="saas-empty">
<p class="text-sm text-surface-600">组织数据不可用</p>
</div>
{/if}
@@ -0,0 +1,310 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } 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 { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
requestRate: '请求速率',
requestBodySize: '请求体大小',
agentConcurrency: '并发数',
admissionQueueLength: '队列长度',
admissionQueueWait: '队列等待',
fileSize: '单文件大小',
attachmentCount: '附件数',
archiveExpansion: '归档展开',
projectStorage: '项目存储',
organizationStorage: '组织存储',
memberCount: '成员数',
projectCount: '项目数',
teamCount: '团队数',
folderCount: '文件夹数',
sessionCount: '会话数',
runWallTime: '运行时长',
runTurns: '对话轮次',
runToolCalls: '工具调用数',
toolWallTime: '工具执行时长',
runOutputSize: '输出大小',
processMemory: '内存',
processCpu: 'CPU',
processCount: '进程数',
};
// Logical groupings for higher information density.
const GROUPS: { title: string; dims: CapacityDimension[] }[] = [
{ title: 'HTTP 接入', dims: ['requestRate', 'requestBodySize'] },
{ title: '智能体运行', dims: ['agentConcurrency', 'runWallTime', 'runTurns', 'runToolCalls', 'toolWallTime', 'runOutputSize'] },
{ title: '接纳队列', dims: ['admissionQueueLength', 'admissionQueueWait'] },
{ title: '附件与存储', dims: ['fileSize', 'attachmentCount', 'archiveExpansion', 'projectStorage', 'organizationStorage'] },
{ title: '组织配额', dims: ['memberCount', 'projectCount', 'teamCount', 'folderCount', 'sessionCount'] },
{ title: '进程资源', dims: ['processMemory', 'processCpu', 'processCount'] },
];
let view = $state<CapacityPolicyView | null>(null);
let drafts = $state<Partial<Record<CapacityDimension, string | number>>>({});
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
view = await api.capacityPolicy(slug);
drafts = {};
for (const row of view.dimensions) {
drafts[row.dimension] = row.organizationLimit === null ? '' : String(row.organizationLimit);
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function draftText(dim: CapacityDimension): string {
const raw = drafts[dim];
if (raw === undefined || raw === null) return '';
return String(raw).trim();
}
// Inline validation: surfaced as the user edits, not on submit. Returns null
// when the draft is empty (means "use platform ceiling") or valid.
function draftError(row: CapacityDimensionRow): string | null {
const text = draftText(row.dimension);
if (text === '') return null;
const n = Number.parseInt(text, 10);
if (!Number.isFinite(n) || n < 1) return '需为正整数';
if (row.platformCeiling !== null && n > row.platformCeiling) return `不得超过 ${row.platformCeiling}`;
return null;
}
// Live effective value: min(platform ceiling, draft). Reflects the draft
// before save so the user sees the outcome as they type.
function liveEffective(row: CapacityDimensionRow): number | null {
if (row.platformCeiling === null) return null;
const text = draftText(row.dimension);
if (text === '') return row.platformCeiling;
const n = Number.parseInt(text, 10);
if (!Number.isFinite(n) || n < 1) return row.platformCeiling;
return Math.min(row.platformCeiling, n);
}
function isLowered(row: CapacityDimensionRow): boolean {
const eff = liveEffective(row);
return eff !== null && eff < row.platformCeiling!;
}
const hasErrors = $derived(
view?.dimensions.some((row) => draftError(row) !== null) ?? false,
);
async function save() {
if (!view || hasErrors) return;
saving = true;
const limits: Partial<Record<CapacityDimension, number | null>> = {};
for (const row of view.dimensions) {
const text = draftText(row.dimension);
limits[row.dimension] = text === '' ? null : Number.parseInt(text, 10);
}
try {
view = await api.setCapacityPolicy(slug, { limits });
drafts = {};
for (const row of view.dimensions) {
drafts[row.dimension] = row.organizationLimit === null ? '' : String(row.organizationLimit);
}
toastSuccess('容量策略已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="容量策略"
description="平台上限不可突破。组织可在此设更低限制,未设置时按平台上限执行。"
>
{#snippet actions()}
<button class="saas-btn-primary" disabled={saving || !view || hasErrors} onclick={save}>
{saving ? '保存中…' : '保存'}
</button>
{/snippet}
</PageHeader>
<p class="saas-muted mb-6">
未配置平台上限的维度暂不可设置组织限制。输入框留空即沿用平台上限。
</p>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if view}
{#if view.dimensions.length === 0}
<EmptyState title="暂无容量维度" description="容量维度由平台定义。" />
{:else}
{@const policy = view}
<div class="grid gap-4 md:grid-cols-2">
{#each GROUPS as group}
{@const rows = group.dims
.map((d) => policy.dimensions.find((r) => r.dimension === d))
.filter((r): r is CapacityDimensionRow => r !== undefined)}
{#if rows.length > 0}
<section class="saas-card">
<header class="group-header">
<h3 class="group-title">{group.title}</h3>
<span class="group-count">{rows.length}</span>
</header>
<div class="dim-list">
{#each rows as row (row.dimension)}
{@const err = draftError(row)}
{@const eff = liveEffective(row)}
{@const lowered = isLowered(row)}
{@const disabled = row.platformCeiling === null}
<div class="dim-row" class:dim-row-error={err !== null}>
<div class="dim-label">{DIMENSION_LABELS[row.dimension] ?? row.dimension}</div>
<div class="dim-ceiling">
{#if disabled}
<span class="text-surface-400">未配置</span>
{:else}
<span class="tabular-nums">平台 ≤ {row.platformCeiling}</span>
{/if}
</div>
<input
class="saas-input dim-input"
type="number"
placeholder="用平台值"
disabled={disabled}
bind:value={drafts[row.dimension]}
/>
<div class="dim-effective">
{#if eff === null}
<span class="text-surface-400"></span>
{:else if lowered}
<span class="saas-badge saas-badge-primary tabular-nums">有效 {eff}</span>
{:else}
<span class="saas-badge saas-badge-neutral tabular-nums">有效 {eff}</span>
{/if}
</div>
{#if err !== null}
<div class="dim-error">{err}</div>
{/if}
</div>
{/each}
</div>
</section>
{/if}
{/each}
</div>
{/if}
{:else}
<EmptyState title="容量数据不可用" description="无法加载容量策略。" />
{/if}
<style>
.group-header {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-surface-200);
background: var(--color-surface-100);
}
.group-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-surface-800);
letter-spacing: 0.02em;
}
.group-count {
font-size: 0.6875rem;
color: var(--color-surface-500);
}
.dim-list {
display: flex;
flex-direction: column;
}
.dim-row {
display: grid;
grid-template-columns: minmax(5rem, 1fr) auto minmax(7rem, 8.5rem) 6.5rem;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--color-surface-100);
}
.dim-row:last-child {
border-bottom: none;
}
.dim-row-error {
background: color-mix(in oklab, var(--color-error-100) 40%, transparent);
}
.dim-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-surface-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dim-ceiling {
font-size: 0.6875rem;
color: var(--color-surface-500);
white-space: nowrap;
}
.dim-input {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
text-align: right;
}
.dim-effective {
display: flex;
justify-content: flex-end;
}
.dim-effective > .saas-badge {
width: 100%;
justify-content: center;
}
.dim-error {
grid-column: 1 / -1;
font-size: 0.6875rem;
color: var(--color-error-700);
padding-bottom: 0.2rem;
}
@media (max-width: 480px) {
.dim-row {
grid-template-columns: 1fr 5rem;
grid-template-rows: auto auto auto;
row-gap: 0.25rem;
}
.dim-label {
grid-column: 1;
}
.dim-ceiling {
grid-column: 2;
text-align: right;
}
.dim-input {
grid-column: 1 / -1;
}
.dim-effective {
grid-column: 1 / -1;
justify-content: flex-start;
}
.dim-effective > .saas-badge {
width: auto;
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,176 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type FeishuApplicationConnection } from '$lib/api';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let connection = $state<FeishuApplicationConnection | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let appId = $state('');
let appSecret = $state('');
let botOpenId = $state('');
let verificationToken = $state('');
let encryptKey = $state('');
let saving = $state(false);
let disabling = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.feishuApplication(slug);
connection = res.connection;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function resetForm() {
appId = '';
appSecret = '';
botOpenId = '';
verificationToken = '';
encryptKey = '';
}
async function save() {
const id = appId.trim();
const secret = appSecret.trim();
const bot = botOpenId.trim();
if (id === '' || secret === '' || bot === '') {
toastError('App ID、App Secret、Bot Open ID 均为必填');
return;
}
saving = true;
const body: {
appId: string;
appSecret: string;
botOpenId: string;
verificationToken?: string;
encryptKey?: string;
} = { appId: id, appSecret: secret, botOpenId: bot };
const vt = verificationToken.trim();
if (vt !== '') body.verificationToken = vt;
const ek = encryptKey.trim();
if (ek !== '') body.encryptKey = ek;
try {
connection = await api.rotateFeishuApplication(slug, body);
resetForm();
toastSuccess('飞书应用凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable() {
if (!connection) return;
if (!confirm('停用后该组织将无法收发飞书消息,确定停用?')) return;
disabling = true;
try {
connection = await api.disableFeishuApplication(slug);
toastSuccess('已停用飞书应用连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="飞书应用"
description="本组织绑定的飞书应用凭据(ADR-0021:组织与应用 1:1)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
{#if connection}
<div class="saas-card-pad mb-6">
<p class="saas-section-title mb-3">当前连接</p>
<dl class="space-y-2.5 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">状态</dt>
<dd><span class="saas-badge-success">{connection.status}</span></dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">App 指纹</dt>
<dd class="font-mono text-xs text-surface-800">{connection.appFingerprint}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">版本</dt>
<dd class="tabular-nums">{connection.activeVersion ?? '—'}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">更新于</dt>
<dd class="text-surface-600">{fmtDate(connection.updatedAt)}</dd>
</div>
</dl>
{#if connection.status !== 'DISABLED'}
<div class="mt-5 flex items-center justify-end gap-3 border-t border-surface-100 pt-4">
<button class="saas-btn-ghost" onclick={disable} disabled={disabling}>
{disabling ? '停用中…' : '停用连接'}
</button>
</div>
{/if}
</div>
{:else}
<div class="saas-card-pad mb-6">
<p class="text-sm text-surface-700">本组织尚未绑定飞书应用。填写下方凭据以创建连接。</p>
</div>
{/if}
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">{connection ? '轮换凭据' : '创建连接'}</h3>
<p class="saas-muted mb-4">
密钥仅写入新版本,旧版本归档。{#if connection}App ID 不可变更,须与现有应用一致。{/if}
</p>
<div class="grid gap-5">
<div>
<Label.Root class="saas-label" for="app-id">App ID</Label.Root>
<input id="app-id" class="saas-input font-mono text-sm" bind:value={appId} />
</div>
<div>
<Label.Root class="saas-label" for="app-secret">App Secret</Label.Root>
<input id="app-secret" class="saas-input" type="password" bind:value={appSecret} />
</div>
<div>
<Label.Root class="saas-label" for="bot-open-id">Bot Open ID</Label.Root>
<input id="bot-open-id" class="saas-input font-mono text-sm" bind:value={botOpenId} />
</div>
<div>
<Label.Root class="saas-label" for="verification-token">Verification Token(可选)</Label.Root>
<input id="verification-token" class="saas-input" type="password" bind:value={verificationToken} />
</div>
<div>
<Label.Root class="saas-label" for="encrypt-key">Encrypt Key(可选)</Label.Root>
<input id="encrypt-key" class="saas-input" type="password" bind:value={encryptKey} />
</div>
</div>
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
@@ -0,0 +1,164 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMember } from '$lib/api';
import { fmtDate } from '$lib/format';
import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants';
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 SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] }));
const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / ');
let members = $state<OrgMember[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newOpenId = $state('');
let newName = $state('');
let newRole = $state<string>('MEMBER');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.members(slug);
members = res.members;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function addMember() {
if (!newOpenId.trim()) return;
adding = true;
try {
const m = await api.addMember(slug, {
feishuOpenId: newOpenId.trim(),
role: newRole,
...(newName.trim() ? { displayName: newName.trim() } : {}),
});
members = [...members, m].sort((a, b) => a.role.localeCompare(b.role) || a.createdAt.localeCompare(b.createdAt));
newOpenId = '';
newName = '';
toastSuccess('成员已添加');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function changeRole(m: OrgMember, role: string) {
try {
await api.setMemberRole(slug, m.userId, role);
await load();
toastSuccess('角色已更新');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function revoke(m: OrgMember) {
if (!confirm(`移除成员 ${m.displayName} 出本组织?`)) return;
try {
await api.revokeMember(slug, m.userId);
await load();
toastSuccess('成员已移除');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader title="成员与权限" description="管理组织角色:所有者与管理员可访问本后台,成员不可。" />
{#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 md:grid-cols-[1.2fr_1fr_12rem_auto]">
<input class="saas-input" placeholder="飞书 open_id" bind:value={newOpenId} />
<input class="saas-input" placeholder="显示名(可选)" bind:value={newName} />
<SelectField items={roleItems} bind:value={newRole} />
<button class="saas-btn-primary" onclick={addMember} disabled={adding}>
{adding ? '添加中…' : '添加成员'}
</button>
</div>
</div>
<div class="saas-card overflow-hidden">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
<h2 class="text-sm font-semibold">成员列表</h2>
<span class="saas-badge-neutral">{members.length}</span>
</div>
{#if members.length === 0}
<EmptyState title="暂无成员" description="使用上方表单按飞书 open_id 添加成员。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>用户</th>
<th>open_id</th>
<th>组织角色</th>
<th>加入时间</th>
<th></th>
</tr>
</thead>
<tbody>
{#each members as m}
<tr>
<td>
<div class="flex items-center gap-2.5">
{#if m.avatarUrl}
<img src={m.avatarUrl} alt="" class="h-7 w-7 border border-surface-300 object-cover" />
{:else}
<div
class="flex h-7 w-7 items-center justify-center border border-primary-300 bg-primary-100 text-xs font-semibold text-primary-800"
>
{m.displayName.slice(0, 1)}
</div>
{/if}
<span class="font-medium">{m.displayName}</span>
</div>
</td>
<td class="font-mono text-xs text-surface-700">{m.feishuOpenId}</td>
<td class="min-w-36">
<SelectField
items={roleItems}
value={m.role}
onchange={(role) => {
if (role !== m.role) changeRole(m, role);
}}
/>
</td>
<td class="text-surface-700">{fmtDate(m.createdAt)}</td>
<td class="text-right">
<button class="saas-btn-danger py-1! text-sm" onclick={() => revoke(m)}>移除</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<p class="border-t border-surface-300 px-5 py-3 text-xs text-surface-600">
组织角色控制后台访问;项目级权限由「项目」页团队授权({permHint})决定。
</p>
</div>
{/if}
@@ -0,0 +1,205 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import FolderTree from '$lib/components/FolderTree.svelte';
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 { Label } from 'bits-ui';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { fmtDate } from '$lib/format';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const org = $derived(
($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null,
);
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null);
let myProjects = $state<ExplorerProject[] | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let showFolderModal = $state(false);
let folderName = $state('');
let folderParent = $state('');
let showProjectModal = $state(false);
let projectName = $state('');
let projectFolder = $state('');
async function load() {
loading = true;
error = null;
try {
if (isAdmin) {
data = await api.explorer(slug);
myProjects = null;
} else {
myProjects = (await api.myProjects(slug)).projects;
data = null;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function createFolder() {
if (!folderName.trim()) return;
try {
await api.createFolder(slug, {
name: folderName.trim(),
...(folderParent ? { parentId: folderParent } : {}),
});
folderName = '';
folderParent = '';
showFolderModal = false;
await load();
toastSuccess('文件夹已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function createProject() {
if (!projectName.trim()) return;
try {
const res = await api.createProject(slug, {
name: projectName.trim(),
...(projectFolder ? { folderId: projectFolder } : {}),
});
projectName = '';
projectFolder = '';
showProjectModal = false;
window.location.href = `/admin/org/${slug}/projects/${res.id}`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
function folderPath(f: ExplorerFolder): string {
if (!data) return f.name;
const parts: string[] = [f.name];
let cur: ExplorerFolder | undefined = f;
while (cur?.parentId) {
const parent = data.folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
function folderItems() {
if (!data) return [{ value: '', label: '(根)' }];
return [{ value: '', label: '(根)' }, ...data.folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
$effect(() => {
if (slug && org) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if isAdmin && data}
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
{#snippet actions()}
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button>
<button class="saas-btn-primary" onclick={() => (showProjectModal = true)}>新建项目</button>
{/snippet}
</PageHeader>
<div class="saas-card p-2 sm:p-3">
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
{:else}
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} />
{/if}
</div>
<Modal bind:open={showFolderModal} title="新建文件夹">
<Label.Root class="saas-label" for="folder-name">名称</Label.Root>
<input
id="folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') createFolder();
}}
/>
{#if data && data.folders.length > 0}
<p class="saas-label">父文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={folderParent} />
</div>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={createFolder}>创建</button>
</div>
</Modal>
<Modal bind:open={showProjectModal} title="新建项目">
<Label.Root class="saas-label" for="project-name">项目名</Label.Root>
<input
id="project-name"
class="saas-input mb-4"
bind:value={projectName}
onkeydown={(e) => {
if (e.key === 'Enter') createProject();
}}
/>
{#if data && data.folders.length > 0}
<p class="saas-label">文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={projectFolder} />
</div>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showProjectModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={createProject}>创建</button>
</div>
</Modal>
{:else if myProjects !== null}
<PageHeader title="我的项目" description="你拥有访问授权的项目。">
{#snippet actions()}
<span class="saas-badge-neutral">仅显示已授权项目</span>
{/snippet}
</PageHeader>
<div class="saas-card overflow-hidden">
{#if myProjects.length === 0}
<EmptyState title="暂无可访问项目" description="当团队被授予项目访问权限时,项目会出现在这里。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>飞书群</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
{#each myProjects as p}
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}>
<td class="font-medium">{p.name}</td>
<td class="font-mono text-xs">{p.binding ? ` ${p.binding.chatId}` : '—'}</td>
<td class="text-surface-700">{fmtDate(p.createdAt)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{:else}
<EmptyState title="项目数据不可用" description="无法加载项目列表。" />
{/if}
@@ -0,0 +1,306 @@
<script lang="ts">
import { page } from '$app/state';
import {
api,
type ProjectDetail,
type TeamAccessEntry,
type TeamRow,
type SessionSummary,
type ExplorerData,
} from '$lib/api';
import { fmtDate, permissionRoleLabel } from '$lib/format';
import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants';
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 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 ?? '');
const projectId = $derived(page.params.projectId ?? '');
const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] }));
const roleChain = `${PERMISSION_ROLE_LABELS.READ}${PERMISSION_ROLE_LABELS.EDIT}${PERMISSION_ROLE_LABELS.MANAGE}`;
let proj = $state<ProjectDetail | null>(null);
let access = $state<TeamAccessEntry[]>([]);
let sessions = $state<SessionSummary[]>([]);
let teams = $state<TeamRow[]>([]);
let explorer = $state<ExplorerData | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let grantTeam = $state('');
let grantRole = $state<string>('EDIT');
let moveFolder = $state('');
const actorIsOrgAdmin = $derived(proj?.actorIsOrgAdmin ?? false);
const actorCanManage = $derived(proj?.actorCanManageProject ?? false);
async function load() {
loading = true;
error = null;
try {
// Project detail + team-access list are gated to project read/oversight.
const [p, a] = await Promise.all([api.project(slug, projectId), api.teamAccess(slug, projectId)]);
proj = p;
access = a.access;
// Team list is needed for grant UI whenever the actor has project MANAGE
// (org admin or member). Sessions/explorer stay org-admin oversight only.
const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true;
const [s, t, e] = await Promise.all([
p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }),
needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }),
p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null),
]);
sessions = s.sessions;
teams = t.teams;
explorer = e;
if (p.actorIsOrgAdmin) {
moveFolder = p.folderId ?? '';
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function rename() {
if (!proj) return;
const name = prompt('新名称', proj.name);
if (!name) return;
try {
await api.renameProject(slug, projectId, name);
await load();
toastSuccess('已重命名');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function archiveBinding() {
if (!confirm('解绑当前飞书群? 用户将无法通过该群触发智能体。')) return;
try {
await api.archiveBinding(slug, projectId);
await load();
toastSuccess('已解绑飞书群');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function archiveProject() {
if (!confirm(`归档项目 ${proj?.name}?`)) return;
try {
await api.archiveProject(slug, projectId);
window.location.href = `/admin/org/${slug}/projects`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function move() {
try {
await api.moveProject(slug, projectId, moveFolder || null);
await load();
toastSuccess('已移动');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function grant() {
if (!grantTeam) return;
try {
await api.grantTeamAccess(slug, projectId, { teamId: grantTeam, role: grantRole });
grantTeam = '';
await load();
toastSuccess('已授权');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function revoke(t: TeamAccessEntry) {
if (!confirm(`撤销 ${t.teamName} 对此项目的授权?`)) return;
try {
await api.revokeTeamAccess(slug, projectId, t.teamId);
await load();
toastSuccess('已撤销授权');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
function folderItems() {
const items = [{ value: '', label: '(根)' }];
if (!explorer) return items;
return [...items, ...explorer.folders.map((f) => ({ value: f.id, label: f.name }))];
}
function teamItems() {
return [{ value: '', label: '选择团队…' }, ...teams.map((t) => ({ value: t.id, label: `${t.name}${t.slug}` }))];
}
$effect(() => {
if (slug && projectId) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if proj}
<div class="mb-2">
<a
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>
{@const detail = proj}
<PageHeader title={detail.name} description={`项目是权限边界;通过团队授予 ${roleChain}。`}>
{#snippet actions()}
{#if actorIsOrgAdmin}
<button class="saas-btn-secondary py-1.5! text-sm" onclick={rename}>重命名</button>
{#if detail.binding}
<button class="saas-btn-secondary py-1.5! text-sm" onclick={archiveBinding}>解绑飞书群</button>
{/if}
<button class="saas-btn-danger py-1.5! text-sm" onclick={archiveProject}>归档</button>
{/if}
{/snippet}
</PageHeader>
<div class="saas-card-pad mb-6">
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">工作区路径</dt>
<dd class="mt-0.5 break-all font-mono text-xs text-surface-700">{proj.workspaceDir}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">创建者</dt>
<dd class="mt-0.5 text-surface-800">
{proj.createdBy ? `${proj.createdBy.displayName} (${proj.createdBy.feishuOpenId})` : '—'}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">文件夹</dt>
<dd class="mt-0.5 text-surface-800">{proj.folder ? proj.folder.name : '(根)'}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">飞书群</dt>
<dd class="mt-0.5 text-surface-800">
{#if proj.binding}
<span class="saas-badge-success mr-1">已绑定</span>
<span class="font-mono text-xs">{proj.binding.chatId}</span>
<span class="text-surface-600"> · {fmtDate(proj.binding.createdAt)}</span>
{:else}
<span class="saas-badge-neutral">未绑定</span>
{/if}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">创建时间</dt>
<dd class="mt-0.5 text-surface-800">{fmtDate(proj.createdAt)}</dd>
</div>
</dl>
{#if explorer}
<div class="mt-5 flex flex-wrap items-end gap-2 border-t border-surface-100 pt-4">
<div class="min-w-48 flex-1">
<p class="saas-label">移动到文件夹</p>
<SelectField items={folderItems()} bind:value={moveFolder} />
</div>
<button class="saas-btn-secondary" onclick={move}>移动</button>
</div>
{/if}
</div>
<div class="saas-card-pad mb-6">
<h3 class="saas-section-title mb-1">团队授权</h3>
<p class="saas-muted mb-4">通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。</p>
{#if actorCanManage}
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_10rem_auto]">
<SelectField items={teamItems()} bind:value={grantTeam} />
<SelectField items={roleItems} bind:value={grantRole} />
<button class="saas-btn-primary" onclick={grant}>授权</button>
</div>
{:else}
<p class="mb-4 text-xs text-surface-600">需要项目 MANAGE 授权才能增删团队访问。</p>
{/if}
{#if access.length === 0}
<EmptyState title="暂无团队授权" description="选择团队并授予角色以开放项目访问。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>团队</th>
<th>标识</th>
<th>角色</th>
<th></th>
</tr>
</thead>
<tbody>
{#each access as g}
<tr>
<td class="font-medium">{g.teamName}</td>
<td class="font-mono text-xs">/{g.teamSlug}</td>
<td><span class="saas-badge-primary">{permissionRoleLabel(g.role)}</span></td>
<td class="text-right">
{#if actorCanManage}
<button class="saas-btn-danger py-1! text-xs" onclick={() => revoke(g)}>撤销</button>
{:else}
<span class="text-xs text-surface-500"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{#if actorIsOrgAdmin}
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold">智能体会话</h3>
</div>
{#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>供应方 / 角色</th>
<th>模型</th>
<th>运行次数</th>
<th>更新</th>
</tr>
</thead>
<tbody>
{#each sessions as s}
<tr>
<td class="font-mono text-xs">{s.provider} / {s.roleId}</td>
<td class="font-mono text-xs">{s.model}</td>
<td class="tabular-nums">{s.runCount}</td>
<td class="text-surface-700">{fmtDate(s.updatedAt)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
{:else}
<div class="saas-empty">
<p class="text-sm text-surface-600">项目数据不可用</p>
</div>
{/if}
@@ -0,0 +1,169 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api';
import { fmtDate, providerModeLabel } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let connections = $state<ProviderConnectionRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let providerId = $state('');
let baseUrl = $state('');
let authToken = $state('');
let anthropicApiKey = $state('');
let saving = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.providerConnections(slug);
connections = res.connections;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startRotate(row: ProviderConnectionRow) {
providerId = row.providerId;
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
function resetForm() {
providerId = '';
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
async function save() {
const id = providerId.trim();
if (id === '') {
toastError('请填写供应方 ID');
return;
}
const url = baseUrl.trim();
const token = authToken.trim();
if (url === '' || token === '') {
toastError('接口地址与访问令牌均为必填');
return;
}
saving = true;
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
baseUrl: url,
authToken: token,
};
const key = anthropicApiKey.trim();
if (key !== '') body.anthropicApiKey = key;
try {
await api.rotateProviderConnection(slug, id, body);
toastSuccess('凭据已轮换');
resetForm();
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="模型供应方"
description="本组织的模型供应方连接。BYOK 由组织所有者/管理员轮换;平台托管连接由平台管理员配置。凭据按组织隔离,缺失或校验失败即拒绝运行(fail-closed)。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">连接</h3>
</div>
{#if connections.length === 0}
<div class="saas-empty"><p class="text-sm text-surface-600">尚无供应方连接</p></div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>供应方</th>
<th>凭据模式</th>
<th>状态</th>
<th>版本</th>
<th>更新于</th>
<th></th>
</tr>
</thead>
<tbody>
{#each connections as row}
<tr>
<td class="font-mono text-sm">{row.providerId}</td>
<td>{providerModeLabel(row.mode)}</td>
<td>{row.status}</td>
<td class="tabular-nums">{row.activeVersion ?? '—'}</td>
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
<td>
{#if row.mode === 'BYOK'}
<button class="saas-btn-ghost px-2! py-1! text-xs" onclick={() => startRotate(row)}>轮换</button>
{:else}
<span class="text-xs text-surface-500">平台管理</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">轮换 BYOK 凭据</h3>
<p class="saas-muted mb-4">
密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。
</p>
<div class="grid gap-5">
<div>
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} placeholder="openrouter" />
</div>
<div>
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
<input id="base-url" class="saas-input" placeholder="https://openrouter.ai/api" bind:value={baseUrl} />
</div>
<div>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
</div>
<div>
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
<input id="anthropic-key" class="saas-input" type="password" bind:value={anthropicApiKey} />
</div>
</div>
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
@@ -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}
@@ -0,0 +1,233 @@
<script lang="ts">
import { Collapsible } from 'bits-ui';
import { page } from '$app/state';
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
import { fmtDate } from '$lib/format';
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 { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let teams = $state<TeamRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newSlug = $state('');
let newName = $state('');
let newDesc = $state('');
let adding = $state(false);
let expandedId = $state<string | null>(null);
let teamMembers = $state<TeamMemberRow[]>([]);
let memberInput = $state('');
let loadingMembers = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.teams(slug);
teams = res.teams;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
async function createTeam() {
if (!newSlug.trim() || !newName.trim()) return;
const teamSlug = newSlug.trim().toLowerCase();
if (!SLUG_RE.test(teamSlug)) {
toastError('标识须为小写字母数字,可用连字符连接');
return;
}
adding = true;
try {
await api.createTeam(slug, {
slug: teamSlug,
name: newName.trim(),
...(newDesc.trim() ? { description: newDesc.trim() } : {}),
});
newSlug = '';
newName = '';
newDesc = '';
await load();
toastSuccess('团队已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function archiveTeam(t: TeamRow) {
if (!confirm(`归档团队 ${t.name}? 归档后该团队不再解析为项目授权主体。`)) return;
try {
await api.archiveTeam(slug, t.id);
if (expandedId === t.id) expandedId = null;
await load();
toastSuccess('团队已归档');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function openMembers(t: TeamRow) {
if (expandedId === t.id) return;
expandedId = t.id;
loadingMembers = true;
memberInput = '';
try {
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
loadingMembers = false;
}
}
function onExpandChange(t: TeamRow, open: boolean) {
if (open) void openMembers(t);
else if (expandedId === t.id) expandedId = null;
}
async function addMember(t: TeamRow) {
if (!memberInput.trim()) return;
const v = memberInput.trim();
try {
await api.addTeamMember(slug, t.id, v.startsWith('ou') ? { feishuOpenId: v } : { userId: v });
memberInput = '';
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
await load();
toastSuccess('已加入团队');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function revokeMember(t: TeamRow, m: TeamMemberRow) {
if (!confirm(`将 ${m.displayName} 移出团队 ${t.name}?`)) return;
try {
await api.revokeTeamMember(slug, t.id, m.userId);
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
await load();
toastSuccess('已移出团队');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader title="团队" description="团队是项目授权主体,可被授予只读、编辑或管理权限。" />
{#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 md:grid-cols-[1fr_1fr_1.2fr_auto]">
<input class="saas-input" placeholder="标识(如 math-g7" bind:value={newSlug} />
<input class="saas-input" placeholder="名称" bind:value={newName} />
<input class="saas-input" placeholder="描述(可选)" bind:value={newDesc} />
<button class="saas-btn-primary" onclick={createTeam} disabled={adding}>新建</button>
</div>
</div>
{#if teams.length === 0}
<div class="saas-card">
<EmptyState title="暂无团队" description="创建团队后,可在项目页授权项目访问。" />
</div>
{:else}
<div class="space-y-3">
{#each teams as t (t.id)}
<Collapsible.Root
class="saas-card p-5"
open={expandedId === t.id}
onOpenChange={(open) => onExpandChange(t, open)}
>
<div class="flex flex-wrap items-center gap-2.5">
<span class="text-base font-semibold text-surface-900">{t.name}</span>
<span class="font-mono text-xs text-surface-600">/{t.slug}</span>
<span class="saas-badge-neutral">{t.memberCount} 成员</span>
<div class="ml-auto flex flex-wrap gap-2">
<Collapsible.Trigger class="saas-btn-secondary py-1.5! text-sm">
{expandedId === t.id ? '收起' : '管理成员'}
</Collapsible.Trigger>
<button type="button" class="saas-btn-danger py-1.5! text-sm" onclick={() => archiveTeam(t)}>
归档
</button>
</div>
</div>
{#if t.description}
<p class="mt-1.5 text-sm text-surface-700">{t.description}</p>
{/if}
<p class="mt-1 text-xs text-surface-600">创建于 {fmtDate(t.createdAt)}</p>
<Collapsible.Content>
<div class="mt-4 border-t border-surface-200 pt-4">
{#if loadingMembers && expandedId === t.id}
<p class="text-sm text-surface-600">加载中…</p>
{:else if expandedId === t.id}
<div class="mb-4 flex gap-2">
<input
class="saas-input"
placeholder="飞书 open_id 或用户 id"
bind:value={memberInput}
onkeydown={(e) => {
if (e.key === 'Enter') addMember(t);
}}
/>
<button class="saas-btn-secondary shrink-0" onclick={() => addMember(t)}>加入</button>
</div>
{#if teamMembers.length === 0}
<p class="py-3 text-center text-sm text-surface-600">团队暂无成员</p>
{:else}
<table class="data-table">
<thead>
<tr>
<th>成员</th>
<th>open_id</th>
<th>加入时间</th>
<th></th>
</tr>
</thead>
<tbody>
{#each teamMembers as m}
<tr>
<td class="font-medium">{m.displayName}</td>
<td class="font-mono text-xs">{m.feishuOpenId}</td>
<td class="text-surface-700">{fmtDate(m.createdAt)}</td>
<td class="text-right">
<button class="saas-btn-danger py-1! text-xs" onclick={() => revokeMember(t, m)}>
移除
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
</div>
{/if}
<p class="mt-4 text-xs text-surface-600">归档团队会同步撤销其活跃的项目授权。</p>
{/if}
+692
View File
@@ -0,0 +1,692 @@
@import 'tailwindcss';
@import '@skeletonlabs/skeleton';
@import '@skeletonlabs/skeleton/themes/hamlindigo';
@source './**/*.{html,js,svelte,ts}';
@source '../lib/**/*.{html,js,svelte,ts}';
/* Flat industrial: zero radius, higher-contrast surfaces, CJK-first type */
@theme {
--font-sans:
'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Inter', ui-sans-serif, system-ui,
-apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--radius-none: 0;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
--radius-3xl: 0;
--radius-full: 0;
--radius: 0;
}
@layer base {
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings:
'kern' 1,
'liga' 1;
/* Prefer readable CJK metrics over Latin optical sizing */
text-size-adjust: 100%;
}
body {
min-height: 100%;
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.7;
letter-spacing: 0.01em;
/* Slightly cooler industrial surface */
background: var(--color-surface-100);
color: var(--color-surface-950, var(--color-surface-900));
font-variant-east-asian: proportional-width;
}
/* CJK headings: no negative tracking, breathing line-height */
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
line-height: 1.45;
letter-spacing: 0;
color: var(--color-surface-950, var(--color-surface-900));
}
p {
line-height: 1.75;
}
/* Harder focus ring for industrial UI */
:focus-visible {
outline: 2px solid var(--color-primary-600);
outline-offset: 2px;
}
::selection {
background: color-mix(in oklab, var(--color-primary-600) 35%, transparent);
color: var(--color-surface-950, var(--color-surface-900));
}
/* Tables: high-contrast grid, flat */
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
line-height: 1.6;
}
table.data-table thead th {
padding: 0.625rem 0.75rem;
text-align: left;
font-weight: 600;
color: var(--color-surface-700);
background: var(--color-surface-100);
border-bottom: 1px solid var(--color-surface-300);
white-space: nowrap;
letter-spacing: 0.02em;
}
table.data-table tbody td {
padding: 0.75rem;
border-bottom: 1px solid var(--color-surface-200);
vertical-align: middle;
color: var(--color-surface-900);
}
table.data-table tbody tr:hover td {
background: var(--color-surface-100);
}
table.data-table tbody tr:last-child td {
border-bottom: none;
}
}
@layer components {
.saas-card {
border-radius: 0;
border: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
box-shadow: none;
}
.saas-card-pad {
border-radius: 0;
border: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
box-shadow: none;
padding: 1.25rem;
}
.saas-page-title {
font-size: 1.375rem;
line-height: 1.4;
font-weight: 700;
letter-spacing: 0;
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-section-title {
font-size: 1rem;
line-height: 1.5;
font-weight: 600;
letter-spacing: 0;
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-muted {
font-size: 0.875rem;
line-height: 1.65;
color: var(--color-surface-700);
}
.saas-label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.875rem;
line-height: 1.5;
font-weight: 600;
color: var(--color-surface-800);
}
.saas-help {
margin-top: 0.375rem;
font-size: 0.8125rem;
line-height: 1.6;
color: var(--color-surface-600);
}
.saas-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.saas-stat {
border-radius: 0;
border: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
padding: 1rem;
box-shadow: none;
}
.saas-stat-label {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-surface-600);
}
.saas-stat-value {
margin-top: 0.25rem;
font-size: 1.5rem;
line-height: 1.3;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem 1rem;
text-align: center;
line-height: 1.7;
}
.saas-shell {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--color-surface-100);
}
.saas-sidebar {
display: flex;
width: 16rem;
flex-shrink: 0;
flex-direction: column;
border-right: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
}
.saas-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.saas-topbar {
display: flex;
height: 3.5rem;
flex-shrink: 0;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
padding: 0 1.5rem;
/* flat: no glass */
backdrop-filter: none;
}
.saas-content {
flex: 1;
overflow-y: auto;
}
.saas-content-inner {
margin-inline: auto;
width: 100%;
max-width: 72rem;
padding: 1.5rem;
}
@media (min-width: 768px) {
.saas-content-inner {
padding: 2rem;
}
}
.saas-nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
border-radius: 0;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
font-weight: 500;
color: var(--color-surface-700);
border-left: 2px solid transparent;
transition:
color 0.1s,
background-color 0.1s,
border-color 0.1s;
}
.saas-nav-item:hover {
background: var(--color-surface-100);
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-nav-item[data-active='true'] {
background: var(--color-primary-50, var(--color-primary-100));
color: var(--color-primary-800);
border-left-color: var(--color-primary-600);
font-weight: 600;
}
.saas-badge {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
letter-spacing: 0.02em;
}
.saas-badge-neutral {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-surface-300);
background: var(--color-surface-100);
color: var(--color-surface-800);
}
.saas-badge-primary {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-primary-300);
background: var(--color-primary-100);
color: var(--color-primary-800);
}
.saas-badge-success {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-success-300);
background: var(--color-success-100);
color: var(--color-success-800);
}
.saas-badge-warning {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-warning-300);
background: var(--color-warning-100);
color: var(--color-warning-900);
}
.saas-badge-error {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-error-300);
background: var(--color-error-100);
color: var(--color-error-800);
}
.saas-input,
.saas-select,
.saas-textarea {
width: 100%;
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
color: var(--color-surface-950, var(--color-surface-900));
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
outline: none;
transition:
border-color 0.1s,
box-shadow 0.1s;
}
.saas-input::placeholder,
.saas-textarea::placeholder {
color: var(--color-surface-500);
}
.saas-input:focus,
.saas-select:focus,
.saas-textarea:focus {
border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600);
}
.saas-textarea {
font-family: var(--font-mono);
line-height: 1.55;
resize: vertical;
}
.saas-select-trigger {
display: inline-flex;
width: 100%;
align-items: center;
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
color: var(--color-surface-950, var(--color-surface-900));
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
outline: none;
transition:
border-color 0.1s,
box-shadow 0.1s;
cursor: pointer;
text-align: left;
}
.saas-select-trigger:focus-visible,
.saas-select-trigger[data-state='open'] {
border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600);
}
.saas-select-trigger:disabled,
.saas-select-trigger[data-disabled] {
cursor: not-allowed;
opacity: 0.55;
}
.saas-select-trigger [data-placeholder] {
color: var(--color-surface-500);
}
.saas-select-content {
z-index: 70;
max-height: min(18rem, var(--bits-select-content-available-height, 18rem));
width: var(--bits-select-anchor-width);
min-width: var(--bits-select-anchor-width);
overflow: hidden;
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
box-shadow: 4px 4px 0 rgb(15 23 42 / 0.12);
outline: none;
}
.saas-select-item {
display: flex;
align-items: center;
border-radius: 0;
padding: 0.45rem 0.65rem;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-surface-900);
cursor: pointer;
outline: none;
user-select: none;
}
.saas-select-item[data-highlighted] {
background: var(--color-primary-100);
color: var(--color-primary-900);
}
.saas-select-item[data-selected] {
color: var(--color-primary-900);
font-weight: 600;
}
.saas-select-item[data-disabled] {
cursor: not-allowed;
opacity: 0.45;
}
.saas-btn-primary,
.saas-btn-secondary,
.saas-btn-ghost,
.saas-btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
border-radius: 0;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.4;
letter-spacing: 0.01em;
border: 1px solid transparent;
cursor: pointer;
transition:
background-color 0.1s,
color 0.1s,
border-color 0.1s,
opacity 0.1s;
}
.saas-btn-primary:disabled,
.saas-btn-secondary:disabled,
.saas-btn-ghost:disabled,
.saas-btn-danger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.saas-btn-primary {
background: var(--color-primary-600);
border-color: var(--color-primary-700);
color: var(--color-primary-contrast-500, white);
}
.saas-btn-primary:hover:not(:disabled) {
background: var(--color-primary-700);
border-color: var(--color-primary-800);
}
.saas-btn-secondary {
background: var(--color-surface-100);
border-color: var(--color-surface-400);
color: var(--color-surface-900);
}
.saas-btn-secondary:hover:not(:disabled) {
background: var(--color-surface-200);
border-color: var(--color-surface-500);
}
.saas-btn-ghost {
background: transparent;
border-color: transparent;
color: var(--color-surface-800);
}
.saas-btn-ghost:hover:not(:disabled) {
background: var(--color-surface-200);
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-btn-danger {
background: var(--color-error-100);
border-color: var(--color-error-400);
color: var(--color-error-800);
}
.saas-btn-danger:hover:not(:disabled) {
background: var(--color-error-200);
border-color: var(--color-error-500);
}
.saas-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
border-radius: 0;
border: 1px solid var(--color-surface-500);
background: var(--color-surface-50);
color: white;
cursor: pointer;
transition:
background 0.1s,
border-color 0.1s;
}
.saas-checkbox[data-state='checked'] {
background: var(--color-primary-700);
border-color: var(--color-primary-700);
}
.saas-checkbox:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--color-surface-50),
0 0 0 4px var(--color-primary-600);
}
.saas-checkbox[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
/* Square industrial switch (no pill) */
.saas-switch {
position: relative;
display: inline-flex;
width: 2.5rem;
height: 1.35rem;
flex-shrink: 0;
align-items: center;
border-radius: 0;
border: 1px solid var(--color-surface-500);
background: var(--color-surface-300);
padding: 0.125rem;
cursor: pointer;
transition:
background 0.1s,
border-color 0.1s;
}
.saas-switch[data-state='checked'] {
background: var(--color-primary-600);
border-color: var(--color-primary-700);
}
.saas-switch:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--color-surface-50),
0 0 0 4px var(--color-primary-600);
}
.saas-switch[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.saas-switch-thumb {
display: block;
width: 1rem;
height: 1rem;
border-radius: 0;
background: white;
border: 1px solid var(--color-surface-400);
box-shadow: none;
transition: transform 0.1s;
transform: translateX(0);
}
.saas-switch[data-state='checked'] .saas-switch-thumb,
.saas-switch-thumb[data-state='checked'] {
transform: translateX(1.1rem);
border-color: var(--color-primary-800);
}
.saas-modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: rgb(2 6 23 / 0.55);
backdrop-filter: none;
}
.saas-modal {
position: fixed;
left: 50%;
top: 50%;
z-index: 51;
width: calc(100% - 2rem);
max-width: 28rem;
transform: translate(-50%, -50%);
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
padding: 1.5rem;
box-shadow: 6px 6px 0 rgb(15 23 42 / 0.15);
outline: none;
}
.saas-status-panel {
display: flex;
min-height: 100vh;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--color-surface-100);
padding: 1rem;
}
.saas-status-card {
width: 100%;
max-width: 28rem;
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
padding: 2rem;
text-align: center;
box-shadow: 4px 4px 0 rgb(15 23 42 / 0.1);
line-height: 1.7;
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#4F46E5"/>
<path d="M8 10.5h7.5a3.5 3.5 0 0 1 0 7H11v4H8v-11Zm3 4.5h4.5a1.5 1.5 0 0 0 0-3H11v3Z" fill="white"/>
<path d="M20.5 21.5c1.93 0 3.5-1.34 3.5-3s-1.57-3-3.5-3S17 16.84 17 18.5s1.57 3 3.5 3Z" fill="white" opacity=".9"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+18
View File
@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
},
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+17
View File
@@ -0,0 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
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',
},
},
});
+133
View File
@@ -0,0 +1,133 @@
# New Alpha Silo runbook
Use this runbook for a new Organization. A Silo is not merely another row in
the existing database: it has an independent PostgreSQL role/database, Linux
service identity, systemd unit, secret directory/keyring, workspace, skill
store, loopback port, domain, Feishu app and provider credential.
The repeatable entry point is:
```sh
bash hub/deploy/new_silo.sh
```
After collecting the Organization inputs, the wizard shows the assigned
resources and asks once before applying them directly over SSH. The generated
bundle is only a root-secret-safe retry and audit checkpoint; the operator does
not execute it manually during the normal path.
It gathers values and writes a private deployment bundle below
`~/.cph-silo-plans/<instance-id>/`. The directory and all generated files are
mode `0700`/`0600`. Never commit, paste into chat, or copy that directory into
an immutable release. Run the wizard once per Organization; do not edit a copy
from another Organization.
## Inputs to collect
The wizard derives the instance/Organization id from the slug, uses the current
release and managed Alpha defaults, connects to the managed host (currently
`39.107.254.4`), derives
`https://<organization-slug>.educraft.paradigm-edu.net` from the wildcard DNS,
and allocates an unused loopback port plus short workspace path by inspecting
existing Silo environments, listening sockets and workspace paths over
read-only SSH. The Organization administrator supplies:
- Organization display name and slug;
- Feishu App ID and App Secret (the wizard resolves the bot Open ID);
- the first OWNER's Open ID and display name;
- an Organization-exclusive OpenRouter token.
Everything else is platform-managed or derived: instance/Organization id,
server, SSH settings, release, resource ceilings, database coordinates and
generated password, domain, port, workspace, provider/base URL, model/role,
curated skills, concurrency, request/file limits and the managed Mihomo proxy
environment. `NODE_USE_ENV_PROXY=1` is required on Node.js 24 so Hub's built-in
`fetch` actually uses that proxy; merely setting `HTTP_PROXY`/`HTTPS_PROXY` is
not sufficient.
The Feishu app is scoped to this Silo. OAuth users authenticated by that app are
automatically admitted to this Organization; OWNER remains the initial
privileged membership used for controlled administration and bootstrap. An
empty initial team list does not block the Alpha.
To target a replacement platform-managed host, the platform operator may set
`CPH_ALPHA_HOST`, `CPH_ALPHA_DEPLOY_USER`, `CPH_ALPHA_SSH_PORT` and
`CPH_ALPHA_BASE_DOMAIN` before running the wizard. These are fleet controls,
not Organization setup questions.
Obtain a person's Open ID from the Feishu user-get documentation page by
clicking the `user_id` value picker and selecting the person. Configure the
redirect URL shown by the generated `OPERATE.md`; it is required for first-time
OAuth admission.
## Host prerequisites
Before the first Silo on a host, install Node.js 24+, npm, rsync, PostgreSQL
server/client, `pg_isready`, systemd, bubblewrap, socat, `runuser`, `setpriv`,
`pg_dump`, tar, sha256sum, Nginx, Certbot, Typst and a compatible `cph` binary.
Configure outbound proxying independently at host/service level and verify both
GitHub and the selected model provider through it. The wizard does not install
or select proxy nodes.
Use a deployment account with only the required passwordless sudo operations.
The application itself always runs as the installer-created non-root
`cph-<instance-id>` user. PostgreSQL must have a separate login role and logical
database per Silo even when all Silo databases share one PostgreSQL server.
## Execute a generated bundle
Open the bundle's `OPERATE.md` and perform its numbered gates in order:
1. DNS, Feishu redirect URL, permissions and event subscription.
2. Dedicated PostgreSQL role and database.
3. Immutable release publication. Exit 78 is expected only when the first
installer call seeds this instance's keyring and environment template.
4. Root-owned secret installation and off-host keyring recovery copy.
5. Prisma migration, stopped service installation and idempotent bootstrap.
6. Explicit runtime role/skill installation.
7. Nginx/TLS, service start, internal/external health and Feishu acceptance.
8. First off-host backup.
Every command must fail fast. Do not add `|| true` around install, migration,
bootstrap, Nginx validation, health, or backup checks. If a check fails, retain
the unit logs and the exact failed stage before changing configuration.
## Default runtime role and skills
Roles and skills are dynamic Silo state, not release contents. The release
contains only the management CLI. Stage approved skill directories on the host
and install them with `agent_config.sh`; PostgreSQL records role definitions and
skill selections while the versioned skill content lives in the Silo state
directory and is included in backups.
For the current Alpha, upsert the default role with the agreed education
assistant system prompt, model selection, tools policy and approved skill list.
Keep the prompt in a root-controlled staging file, pass it via
`--system-prompt-file`, then verify with `agent_config.sh list`. Do not sync an
operator's entire personal skill directory: each enabled skill must be reviewed
and named explicitly. Typst being installed on the host and the Typst skill
being enabled are separate gates.
## Acceptance gate
A Silo is ready only when all of the following pass:
- its systemd service is active and both loopback and TLS health endpoints pass;
- startup preflight sees exactly the configured Organization plus active Feishu
and provider connections;
- OWNER completes OAuth and can interact with the bot;
- a non-OWNER completes OAuth and can interact with the same app/Organization;
- two Feishu groups bind distinct projects/sessions;
- a restart preserves persisted session cursor behavior;
- the configured provider/model succeeds through the host proxy;
- every enabled skill is listed, and a Typst task succeeds if Typst is enabled;
- the first business backup and separate recovery backup are stored off-host.
## Rollback boundary
For a failed code release, point only this instance back to the previous
immutable release and rerun its installer with the same instance parameters.
Do not roll back a database after migrations unless that release's documented
database compatibility permits it. Preserve the environment, keyring, database,
workspace and skill store. For destructive recovery, stop traffic and use the
separate restore procedure; never substitute another Silo's state.
+75 -2
View File
@@ -1,5 +1,42 @@
# Alpha Silo service installation
For a brand-new Organization, start with the repeatable wizard and end-to-end
operator runbook:
```sh
bash hub/deploy/new_silo.sh
```
See [NEW_SILO_RUNBOOK.md](NEW_SILO_RUNBOOK.md). The remainder of this document
describes the individual installer and maintenance primitives used by the
generated bundle.
## CI: roll Hub + admin SPA by fleet (`educraft` / `educraft-dev`)
`.gitea/workflows/deploy-admin.yml` deploys Hub releases (including the org-admin
SPA) to the managed Alpha host via `deploy_fleet_release.sh`. Fleets share the
host and are selected by the middle DNS label of `HUB_PUBLIC_BASE_URL`:
| Fleet | Domain | CI trigger |
|-------|--------|------------|
| **dev** | `https://<slug>.educraft-dev.paradigm-edu.net` | every push + PR (paths under `hub/`) |
| **prod** | `https://<slug>.educraft.paradigm-edu.net` | push to `main` + tags |
Example prod tenant:
`https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100`.
Required Gitea secret: `DEPLOY_SSH_KEY` (private key for `root@39.107.254.4`).
Optional: `DEPLOY_HOST` / `DEPLOY_USER` / `DEPLOY_SSH_PORT` / `DEPLOY_BASE`.
Set repository var `ALLOW_EMPTY_FLEET=1` until the first silo exists for a fleet.
Local dry-run against the host:
```sh
CPH_FLEET=dev \
PLATFORM_DEPLOY_SSH_KEY=$HOME/.ssh/id_ed25519 \
bash hub/deploy/deploy_fleet_release.sh
```
The supervised alpha runs one Organization per named Silo. The supported host
has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`,
bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`.
@@ -54,6 +91,34 @@ Default state paths are:
/var/cache/cph-hub/org-a
```
Organization Agent roles and skills are runtime configuration. Skill versions
are stored below the Silo state directory (`state/skills`) and are included in
`backup_silo.sh` as `agent-skills.tar`; PostgreSQL stores role bundles, skill
metadata and role-to-skill selection. Operate them as the Silo service user so
content ownership remains correct:
```sh
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh install-skill \
--organization org-a --source /staging/typst --version 1
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh upsert-role \
--organization org-a --role draft --label 草稿 --tools-json null
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh set-role-skills \
--organization org-a --role draft --skills outline,lesson-project,typst
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh list --organization org-a
```
`--tools-json null` means the full registered tool surface; `[]` means no
ordinary tools. SDK-bundled skills and workspace/user setting sources remain
disabled regardless of runtime configuration.
## Bootstrap the only Organization
Prepare a root-owned `0600` JSON file containing
@@ -129,16 +194,24 @@ sudo INSTANCE_ID=org-a \
bash hub/deploy/backup_silo.sh
```
The business set contains the PostgreSQL custom dump and workspace archive. The
The business set contains the PostgreSQL custom dump, workspace archive and
`agent-skills.tar`. The
separate recovery set contains the keyring and environment. Both include
checksums; neither destination may be the live host's only disk.
Restore into a separate drill database/workspace, verify checksums, then run:
Restore into a separate drill database, workspace and skill-store directory;
verify checksums before extracting both tar archives, then run:
```sh
set -a; . /path/to/restored/platform.env; set +a
mkdir -p "$HUB_PROJECT_WORKSPACE_ROOT" "$HUB_SKILL_STORE_ROOT"
tar -xf /path/to/business/workspaces.tar -C "$HUB_PROJECT_WORKSPACE_ROOT"
tar -xf /path/to/business/agent-skills.tar -C "$HUB_SKILL_STORE_ROOT"
node hub/dist/deployment/restore-preflight.js \
--keyring-file /path/to/restored/secret-keyring.json
sudo INSTANCE_ID=org-a ENV_FILE=/path/to/restored/platform.env \
HUB_DIR=/path/to/restored/release/hub \
bash hub/deploy/agent_config.sh verify-store --organization org-a
```
Traffic stays disabled until the sole Organization and every Feishu/provider
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID required}"
ENV_FILE="${ENV_FILE:?ENV_FILE required}"
SERVICE_USER="${SERVICE_USER:-cph-$INSTANCE_ID}"
HUB_DIR="${HUB_DIR:-/srv/curriculum-project-hub/current/hub}"
[ "$(id -u)" -eq 0 ] || { echo "agent config console must run as root" >&2; exit 1; }
[ -r "$ENV_FILE" ] || { echo "environment file is not readable: $ENV_FILE" >&2; exit 1; }
[ -f "$HUB_DIR/dist/deployment/agent-config-cli.js" ] || { echo "Agent config CLI missing below $HUB_DIR" >&2; exit 1; }
id "$SERVICE_USER" >/dev/null 2>&1 || { echo "service user missing: $SERVICE_USER" >&2; exit 1; }
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}"
: "${HUB_SILO_ORGANIZATION_ID:?HUB_SILO_ORGANIZATION_ID missing from ENV_FILE}"
exec runuser --user "$SERVICE_USER" -- \
env -i \
DATABASE_URL="$DATABASE_URL" \
HUB_SILO_ORGANIZATION_ID="$HUB_SILO_ORGANIZATION_ID" \
HUB_SKILL_STORE_ROOT="${HUB_SKILL_STORE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/state/skills}" \
XDG_STATE_HOME="${XDG_STATE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/state}" \
PATH="${PATH:-/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin}" \
node "$HUB_DIR/dist/deployment/agent-config-cli.js" "$@"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Apply a bundle produced by new_silo.sh to the managed Alpha host.
set -euo pipefail
BUNDLE="${1:?usage: apply_new_silo.sh BUNDLE_DIR}"
ANSWERS="$BUNDLE/answers.env"
[ -f "$ANSWERS" ] || { echo "missing $ANSWERS" >&2; exit 1; }
value() { sed -n "s/^$1=//p" "$ANSWERS" | tail -n1; }
INSTANCE_ID="$(value INSTANCE_ID)"
ORG_ID="$(value ORGANIZATION_ID)"
HOST="$(value DEPLOY_HOST)"
SSH_USER="$(value DEPLOY_USER)"
SSH_PORT="$(value DEPLOY_SSH_PORT)"
SSH_KEY="$(value DEPLOY_SSH_KEY)"
BASE="$(value DEPLOY_BASE)"
RELEASE="$(value RELEASE_ID)"
HUB_PORT="$(value HUB_PORT)"
WORKSPACE="$(value WORKSPACE_ROOT)"
MEMORY="$(value MEMORY_MAX)"
CPU="$(value CPU_QUOTA)"
TASKS="$(value TASKS_MAX)"
DB_NAME="$(value DATABASE_NAME)"
DB_USER="$(value DATABASE_USER)"
DB_PASSWORD="$(value DATABASE_PASSWORD)"
PUBLIC_URL="$(value PUBLIC_BASE_URL)"
DOMAIN="${PUBLIC_URL#https://}"
HUB_DIR="$BASE/releases/$RELEASE/hub"
ENV_PATH="$BASE/.secrets/$INSTANCE_ID/platform.env"
KEYRING_PATH="$BASE/.secrets/$INSTANCE_ID/secret-keyring.json"
UNIT="cph-hub-$INSTANCE_ID.service"
SSH=(ssh -i "$SSH_KEY" -p "$SSH_PORT" -o BatchMode=yes "$SSH_USER@$HOST")
SCP=(scp -i "$SSH_KEY" -P "$SSH_PORT")
for file in platform.env bootstrap.json default-role-prompt.md; do
[ -f "$BUNDLE/$file" ] || { echo "missing bundle file: $file" >&2; exit 1; }
done
echo "[1/8] Verify immutable release"
"${SSH[@]}" "test -f '$BASE/releases/$RELEASE/.complete'"
echo "[2/8] Create dedicated database"
if ! "${SSH[@]}" "sudo -u postgres psql -Atqc \"select 1 from pg_database where datname='$DB_NAME'\"" | grep -qx 1; then
printf "CREATE ROLE %s LOGIN PASSWORD '%s';\nCREATE DATABASE %s OWNER %s;\n" \
"$DB_USER" "$DB_PASSWORD" "$DB_NAME" "$DB_USER" | "${SSH[@]}" sudo -u postgres psql -v ON_ERROR_STOP=1
fi
echo "[3/8] Seed instance keyring and service template"
set +e
"${SSH[@]}" "BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY' CPU_QUOTA='$CPU' TASKS_MAX='$TASKS' bash '$HUB_DIR/deploy/install_service.sh'"
status=$?
set -e
[ "$status" -eq 0 ] || [ "$status" -eq 78 ] || exit "$status"
echo "[4/8] Upload root-only configuration"
remote_stage="/root/.cph-bootstrap-$INSTANCE_ID"
"${SSH[@]}" "install -d -o root -g root -m 0700 '$remote_stage'"
"${SCP[@]}" "$BUNDLE/platform.env" "$BUNDLE/bootstrap.json" "$BUNDLE/default-role-prompt.md" "$SSH_USER@$HOST:$remote_stage/"
"${SSH[@]}" "install -o root -g root -m 0600 '$remote_stage/platform.env' '$ENV_PATH'; chmod 0600 '$remote_stage/bootstrap.json' '$remote_stage/default-role-prompt.md'"
echo "[5/8] Migrate, install, and bootstrap"
"${SSH[@]}" "set -euo pipefail; set -a; . '$ENV_PATH'; set +a; node '$HUB_DIR/node_modules/prisma/build/index.js' migrate deploy --schema '$HUB_DIR/prisma/schema.prisma'; BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY' CPU_QUOTA='$CPU' TASKS_MAX='$TASKS' bash '$HUB_DIR/deploy/install_service.sh'; node '$HUB_DIR/dist/deployment/bootstrap-silo-cli.js' --config-file '$remote_stage/bootstrap.json' --keyring-file '$KEYRING_PATH'"
echo "[6/8] Copy curated skills and configure default role"
"${SSH[@]}" "set -euo pipefail; stage='/var/lib/cph-hub/$INSTANCE_ID/state/operator-staging'; install -d -o cph-$INSTANCE_ID -g cph-$INSTANCE_ID -m 0700 \"\$stage/skills\"; install -o cph-$INSTANCE_ID -g cph-$INSTANCE_ID -m 0600 '$remote_stage/default-role-prompt.md' \"\$stage/default-role-prompt.md\"; for source in /var/lib/cph-hub/para-26071100/state/skills/versions/*; do name=\$(sed -n 's/^name: *//p' \"\$source/SKILL.md\" | head -n1); case \"\$name\" in outline|lesson-project|data-processing-spec|typst) cp -a \"\$source\" \"\$stage/skills/\$name\"; chown -R cph-$INSTANCE_ID:cph-$INSTANCE_ID \"\$stage/skills/\$name\";; esac; done; for name in outline lesson-project data-processing-spec typst; do INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' install-skill --organization '$ORG_ID' --source \"\$stage/skills/\$name\" --version 1; done; INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' upsert-role --organization '$ORG_ID' --role draft --label '智能助手' --system-prompt-file \"\$stage/default-role-prompt.md\" --tools-json null; INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' set-role-skills --organization '$ORG_ID' --role draft --skills outline,lesson-project,data-processing-spec,typst; rm -rf \"\$stage\""
echo "[7/8] Configure Nginx and TLS"
printf 'server { listen 80; listen [::]:80; server_name %s; client_max_body_size 2m; location / { proxy_pass http://127.0.0.1:%s; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } }\n' "$DOMAIN" "$HUB_PORT" | "${SSH[@]}" "install -o root -g root -m 0644 /dev/stdin '/etc/nginx/sites-available/$INSTANCE_ID'; ln -sfn '/etc/nginx/sites-available/$INSTANCE_ID' '/etc/nginx/sites-enabled/$INSTANCE_ID'; nginx -t; systemctl reload nginx; certbot --nginx --non-interactive --agree-tos --redirect --register-unsafely-without-email -d '$DOMAIN'"
echo "[8/8] Start and verify"
"${SSH[@]}" "systemctl enable --now '$UNIT'; systemctl is-active --quiet '$UNIT'; curl --fail --silent 'http://127.0.0.1:$HUB_PORT/api/healthz' >/dev/null; rm -f '$remote_stage/bootstrap.json'"
curl --fail --silent --show-error "$PUBLIC_URL/api/healthz" >/dev/null
echo "Deployed $INSTANCE_ID at $PUBLIC_URL"
+9 -1
View File
@@ -9,6 +9,7 @@ KEYRING_FILE="${KEYRING_FILE:?KEYRING_FILE required}"
BUSINESS_BACKUP_DIR="${BUSINESS_BACKUP_DIR:?BUSINESS_BACKUP_DIR required}"
RECOVERY_BACKUP_DIR="${RECOVERY_BACKUP_DIR:?RECOVERY_BACKUP_DIR required}"
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/state/skills}"
[ "$(id -u)" -eq 0 ] || { echo "backup must run as root" >&2; exit 1; }
umask 077
@@ -37,12 +38,14 @@ set +a
: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}"
: "${HUB_PROJECT_WORKSPACE_ROOT:?HUB_PROJECT_WORKSPACE_ROOT missing from ENV_FILE}"
[ -d "$HUB_PROJECT_WORKSPACE_ROOT" ] || { echo "workspace root missing" >&2; exit 1; }
[ -d "$SKILL_STORE_ROOT" ] || { echo "skill store root missing" >&2; exit 1; }
install -d -o root -g root -m 0700 "$BUSINESS_BACKUP_DIR" "$RECOVERY_BACKUP_DIR"
business_root="$(realpath -m "$BUSINESS_BACKUP_DIR")"
recovery_root="$(realpath -m "$RECOVERY_BACKUP_DIR")"
workspace_root="$(realpath -m "$HUB_PROJECT_WORKSPACE_ROOT")"
secret_root="$(realpath -m "$(dirname "$KEYRING_FILE")")"
skill_root="$(realpath -m "$SKILL_STORE_ROOT")"
paths_overlap() {
local left="$1" right="$2"
[ "$left" = "$right" ] || [[ "$left/" == "$right/"* ]] || [[ "$right/" == "$left/"* ]]
@@ -58,6 +61,10 @@ for pair in \
exit 1
fi
done
if paths_overlap "$business_root" "$skill_root" || paths_overlap "$recovery_root" "$skill_root" || paths_overlap "$workspace_root" "$skill_root"; then
echo "backup destinations, workspace and skill store must not overlap: $skill_root" >&2
exit 1
fi
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
business="$business_root/$INSTANCE_ID-$stamp"
recovery="$recovery_root/$INSTANCE_ID-$stamp"
@@ -65,13 +72,14 @@ install -d -o root -g root -m 0700 "$business" "$recovery"
pg_dump --format=custom --file="$business/database.dump" "$DATABASE_URL"
tar --create --file="$business/workspaces.tar" --directory="$HUB_PROJECT_WORKSPACE_ROOT" .
tar --create --file="$business/agent-skills.tar" --directory="$SKILL_STORE_ROOT" .
cp --preserve=mode,ownership,timestamps "$KEYRING_FILE" "$recovery/secret-keyring.json"
cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$recovery/platform.env"
chmod 0600 "$recovery/secret-keyring.json" "$recovery/platform.env"
(
cd "$business"
sha256sum database.dump workspaces.tar > SHA256SUMS
sha256sum database.dump workspaces.tar agent-skills.tar > SHA256SUMS
)
(
cd "$recovery"
@@ -0,0 +1,59 @@
#!/usr/bin/env node
import { readdir, readFile, realpath } from "node:fs/promises";
import { dirname, relative, resolve, sep } from "node:path";
const rootArgument = process.argv[2];
if (!rootArgument) throw new Error("usage: build_legacy_project_manifest.mjs <legacy-workspaces-root>");
const root = await realpath(rootArgument);
const projectFiles = await findProjectFiles(root);
const seenIds = new Set();
const manifest = [];
for (const projectFile of projectFiles) {
const metadata = JSON.parse(await readFile(projectFile, "utf8"));
if (typeof metadata.id !== "string" || metadata.id.trim() === "") {
throw new Error(`project metadata has no id: ${projectFile}`);
}
if (seenIds.has(metadata.id)) throw new Error(`duplicate project id: ${metadata.id}`);
seenIds.add(metadata.id);
if (typeof metadata.name !== "string" || metadata.name.trim() === "") {
throw new Error(`project metadata has no name: ${projectFile}`);
}
const projectRoot = dirname(projectFile);
const sourceRelativePath = relative(root, projectRoot).split(sep).join("/");
const physicalFolderPath = dirname(sourceRelativePath) === "."
? []
: dirname(sourceRelativePath).split("/");
if (metadata.folderPath !== undefined && (
!Array.isArray(metadata.folderPath)
|| metadata.folderPath.some((part) => typeof part !== "string")
|| JSON.stringify(metadata.folderPath) !== JSON.stringify(physicalFolderPath)
)) {
process.stderr.write(`[legacy-manifest] stale metadata folderPath; using physical path: ${projectFile}\n`);
}
manifest.push({
legacyId: metadata.id,
name: metadata.name,
folderPath: physicalFolderPath,
sourceRelativePath,
});
}
manifest.sort((left, right) => left.sourceRelativePath.localeCompare(right.sourceRelativePath, "zh-CN"));
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
async function findProjectFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const projectMetadata = entries.find((entry) => entry.isFile() && entry.name === "project.json");
if (projectMetadata !== undefined) return [resolve(directory, projectMetadata.name)];
const found = [];
for (const entry of entries) {
if (entry.name === ".trash") continue;
const path = resolve(directory, entry.name);
if (entry.isSymbolicLink()) {
process.stderr.write(`[legacy-manifest] skip untracked symbolic link: ${path}\n`);
continue;
}
if (entry.isDirectory()) found.push(...await findProjectFiles(path));
}
return found;
}
+1
View File
@@ -18,6 +18,7 @@ EnvironmentFile=__ENV_FILE__
Environment=HOME=__SERVICE_HOME__
Environment=XDG_STATE_HOME=__STATE_DIR__
Environment=XDG_CACHE_HOME=__CACHE_DIR__
Environment=HUB_SKILL_STORE_ROOT=__SKILL_STORE_ROOT__
Environment=PATH=__RUNTIME_PATH__
# ADR-0024: the root-owned source remains unreadable by the service account;
# systemd materializes a read-only per-unit credential at runtime.
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
# Publish one immutable Hub release (tsc + admin-web SPA) to the managed host,
# then repoint every Silo in the chosen fleet to that release and restart it.
#
# Fleet is selected by the middle DNS label of each Silo's HUB_PUBLIC_BASE_URL:
#
# prod → https://<slug>.educraft.paradigm-edu.net
# dev → https://<slug>.educraft-dev.paradigm-edu.net
#
# Example tenant (prod):
# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100
#
# Configure (CI secrets / env):
# CPH_FLEET required: dev | prod
# PLATFORM_DEPLOY_HOST default 39.107.254.4
# PLATFORM_DEPLOY_USER default root
# PLATFORM_DEPLOY_SSH_KEY required (path to private key)
# PLATFORM_DEPLOY_PORT default 22
# PLATFORM_DEPLOY_BASE default /srv/curriculum-project-hub
# PLATFORM_DEPLOY_RELEASE default git HEAD (must be safe for paths)
# ALLOW_EMPTY_FLEET optional: if 1, succeed when no silo matches
#
# This is a code-roll for existing silos. It does not create databases, domains,
# or Feishu apps — use new_silo.sh / apply_new_silo.sh for that.
set -euo pipefail
FLEET="${CPH_FLEET:?CPH_FLEET required (dev|prod)}"
case "$FLEET" in
dev) FLEET_DOMAIN_SUFFIX="educraft-dev.paradigm-edu.net" ;;
prod) FLEET_DOMAIN_SUFFIX="educraft.paradigm-edu.net" ;;
*)
echo "CPH_FLEET must be dev or prod, got: $FLEET" >&2
exit 1
;;
esac
HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
PORT="${PLATFORM_DEPLOY_PORT:-22}"
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}"
[[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || {
echo "invalid PLATFORM_DEPLOY_RELEASE: $RELEASE_ID" >&2
exit 1
}
ALLOW_EMPTY_FLEET="${ALLOW_EMPTY_FLEET:-0}"
HUB_DIR="$BASE/releases/$RELEASE_ID/hub"
RELEASE_DIR="$BASE/releases/$RELEASE_ID"
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
echo "[fleet] fleet=$FLEET domain=*.$FLEET_DOMAIN_SUFFIX host=$DEPLOY_USER@$HOST release=$RELEASE_ID"
# --- 1. Publish immutable release (shared; flock so parallel fleet jobs do not race) ---
release_ready=false
if ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -f '$RELEASE_DIR/.complete'"; then
release_ready=true
echo "[fleet] release already complete: $RELEASE_DIR"
fi
if [ "$release_ready" = false ]; then
# Drop a prior incomplete tree for this release id (failed CI leave leftovers).
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
set -euo pipefail
if [ -f "$RELEASE_DIR/.complete" ]; then
exit 0
fi
if [ -e "$RELEASE_DIR" ]; then
echo "[fleet] removing incomplete release: $RELEASE_DIR"
rm -rf "$RELEASE_DIR"
fi
mkdir -p "$HUB_DIR"
'
REMOTE
echo "[fleet] rsync hub → $HUB_DIR"
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web SPA)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
set -euo pipefail
if [ -f "$RELEASE_DIR/.complete" ]; then
echo "[fleet] release completed by another job"
exit 0
fi
cd "$HUB_DIR"
npm ci
npm ci --prefix admin-web
npm run audit:production
npm run build
test -f admin-web/build/index.html
touch "$RELEASE_DIR/.complete"
'
REMOTE
fi
# --- 2. Discover silos in this fleet and roll them onto the release ---
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
env BASE="$BASE" HUB_DIR="$HUB_DIR" FLEET="$FLEET" \
FLEET_DOMAIN_SUFFIX="$FLEET_DOMAIN_SUFFIX" ALLOW_EMPTY_FLEET="$ALLOW_EMPTY_FLEET" \
bash -s <<'REMOTE'
set -euo pipefail
fleet_match() {
local public_url="$1"
local host="${public_url#https://}"
host="${host#http://}"
host="${host%%/*}"
host="${host%%:*}"
case "$FLEET" in
prod)
[[ "$host" == *."$FLEET_DOMAIN_SUFFIX" || "$host" == "$FLEET_DOMAIN_SUFFIX" ]] || return 1
# Avoid matching educraft-dev when suffix is educraft.…
[[ "$host" != *.educraft-dev.paradigm-edu.net && "$host" != educraft-dev.paradigm-edu.net ]] || return 1
return 0
;;
dev)
[[ "$host" == *."$FLEET_DOMAIN_SUFFIX" || "$host" == "$FLEET_DOMAIN_SUFFIX" ]]
return
;;
*)
return 1
;;
esac
}
env_value() {
local file="$1"
local key="$2"
# Do not source secrets — only read the KEY=value line.
local line
line="$(grep -E "^${key}=" "$file" | tail -n1 || true)"
printf '%s' "${line#"${key}="}"
}
matched=0
shopt -s nullglob
for env_file in "$BASE"/.secrets/*/platform.env; do
instance_id="$(basename "$(dirname "$env_file")")"
public_url="$(env_value "$env_file" HUB_PUBLIC_BASE_URL)"
if [ -z "$public_url" ]; then
echo "[fleet] skip $instance_id: HUB_PUBLIC_BASE_URL empty"
continue
fi
if ! fleet_match "$public_url"; then
echo "[fleet] skip $instance_id: $public_url (not $FLEET)"
continue
fi
port="$(env_value "$env_file" PORT)"
workspace="$(env_value "$env_file" HUB_PROJECT_WORKSPACE_ROOT)"
unit="cph-hub-${instance_id}.service"
if [ ! -f "/etc/systemd/system/$unit" ]; then
echo "[fleet] skip $instance_id: unit missing ($unit)" >&2
continue
fi
# Prefer unit file ceilings (MemoryMax=16G) over systemctl's byte form.
memory_max="$(sed -n 's/^MemoryMax=//p' "/etc/systemd/system/$unit" | tail -n1)"
cpu_quota="$(sed -n 's/^CPUQuota=//p' "/etc/systemd/system/$unit" | tail -n1)"
tasks_max="$(sed -n 's/^TasksMax=//p' "/etc/systemd/system/$unit" | tail -n1)"
if [ -z "$port" ] || [ -z "$workspace" ] || [ -z "$memory_max" ] || [ -z "$cpu_quota" ] || [ -z "$tasks_max" ]; then
echo "[fleet] error: $instance_id missing PORT/workspace/ceilings (port=$port workspace=$workspace memory=$memory_max cpu=$cpu_quota tasks=$tasks_max)" >&2
exit 1
fi
echo "[fleet] roll $instance_id → $HUB_DIR ($public_url port=$port)"
BASE="$BASE" \
HUB_DIR="$HUB_DIR" \
INSTANCE_ID="$instance_id" \
WORKSPACE_ROOT="$workspace" \
PORT="$port" \
MEMORY_MAX="$memory_max" \
CPU_QUOTA="$cpu_quota" \
TASKS_MAX="$tasks_max" \
bash "$HUB_DIR/deploy/install_service.sh"
systemctl restart "$unit"
systemctl is-active --quiet "$unit"
health="http://127.0.0.1:${port}/api/healthz"
ok=false
for _ in $(seq 1 45); do
if curl --fail --silent "$health" >/dev/null 2>&1; then
ok=true
break
fi
sleep 1
done
if [ "$ok" != true ]; then
curl --fail --silent --show-error "$health" >/dev/null
fi
echo "[fleet] healthy $instance_id ($health)"
matched=$((matched + 1))
done
if [ "$matched" -eq 0 ]; then
msg="[fleet] no silos matched *.$FLEET_DOMAIN_SUFFIX under $BASE/.secrets"
if [ "$ALLOW_EMPTY_FLEET" = "1" ]; then
echo "$msg (ALLOW_EMPTY_FLEET=1)"
exit 0
fi
echo "$msg" >&2
exit 1
fi
echo "[fleet] rolled $matched silo(s) on $FLEET"
REMOTE
echo "[fleet] done fleet=$FLEET release=$RELEASE_ID hub=$HUB_DIR"
+3 -2
View File
@@ -60,9 +60,10 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps, audit and build, then atomically mark the release complete.
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && npm ci && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
"cd '$HUB_DIR' && npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
+5 -1
View File
@@ -23,6 +23,7 @@ SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}"
SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-$STATE_DIR/skills}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:?PORT is required and must be unique on the host}"
@@ -105,6 +106,7 @@ for pair in \
"SERVICE_HOME:$SERVICE_HOME" \
"STATE_DIR:$STATE_DIR" \
"CACHE_DIR:$CACHE_DIR" \
"SKILL_STORE_ROOT:$SKILL_STORE_ROOT" \
"WORKSPACE_ROOT:$WORKSPACE_ROOT" \
"ENV_FILE:$ENV_FILE" \
"KEYRING_FILE:$KEYRING_FILE" \
@@ -281,6 +283,7 @@ provision_directory() {
provision_directory "$SERVICE_HOME"
provision_directory "$STATE_DIR"
provision_directory "$CACHE_DIR"
provision_directory "$SKILL_STORE_ROOT"
provision_directory "$WORKSPACE_ROOT"
# Resolve every provisioned path again and verify uid/gid/mode before writing
@@ -297,6 +300,7 @@ sed \
-e "s|__SERVICE_HOME__|$SERVICE_HOME|g" \
-e "s|__STATE_DIR__|$STATE_DIR|g" \
-e "s|__CACHE_DIR__|$CACHE_DIR|g" \
-e "s|__SKILL_STORE_ROOT__|$SKILL_STORE_ROOT|g" \
-e "s|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
@@ -315,5 +319,5 @@ install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
systemctl daemon-reload
systemctl enable "$SERVICE_UNIT"
echo "[install] installed $SERVICE_UNIT for $SERVICE_USER:$SERVICE_GROUP"
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR workspaces=$WORKSPACE_ROOT"
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR skills=$SKILL_STORE_ROOT workspaces=$WORKSPACE_ROOT"
echo "[install] start with: systemctl start $SERVICE_UNIT"
+412
View File
@@ -0,0 +1,412 @@
#!/usr/bin/env bash
#
# A wizard — walks a human through a manual procedure step by step.
# Generated by the /wizard skill.
#
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
# it. Author the per-step stages below the marker.
set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────
# Wizard library — delightful, consistent UX. Identical across every wizard.
# ──────────────────────────────────────────────────────────────────────────
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1)
else
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED=""
fi
# Author sets these two at the top of the stages section.
TOTAL_STAGES=0
TOTAL_MINUTES=0
_STAGE_INDEX=0
_MINUTES_ELAPSED=0
ENV_FILE="${ENV_FILE:-.env}"
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
WRITTEN_SECRET=() # secret NAMEs set this run
SKIPPED=() # things we couldn't do (e.g. gh missing)
# _clear — wipe the terminal so only the current step is on screen. No-op when
# output isn't a terminal, so piped logs stay readable.
_clear() {
[[ -t 1 ]] || return 0
if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi
}
# banner "Title" — opening frame: what this wizard does and how long it takes.
banner() {
_clear
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
printf '%s %s stages · about %s minutes%s\n\n' \
"$DIM" "$TOTAL_STAGES" "$TOTAL_MINUTES" "$RESET"
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
printf ' later — it remembers values already saved.%s\n' "$RESET"
pause "Ready to start?"
}
# stage "Name" <minutes> — clear the screen, then announce a stage and show
# progress + time remaining. Clearing keeps only the current step on screen.
stage() {
_clear
_STAGE_INDEX=$((_STAGE_INDEX + 1))
local remaining=$((TOTAL_MINUTES - _MINUTES_ELAPSED))
(( remaining < 0 )) && remaining=0
_MINUTES_ELAPSED=$((_MINUTES_ELAPSED + ${2:-0}))
printf '\n%s%s▸ Stage %s/%s · %s%s %s(~%s min left)%s\n' \
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET" "$DIM" "$remaining" "$RESET"
}
# say "..." — a plain instruction line.
say() { printf ' %s\n' "$1"; }
# step "..." — a numbered-feeling action the human takes in the browser.
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
# open_url URL — open in the human's browser, cross-platform incl. WSL.
open_url() {
local url="$1"
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
elif command -v open >/dev/null 2>&1; then open "$url"
else warn "couldn't open a browser — visit it manually: $url"; fi
} >/dev/null 2>&1 || warn "couldn't open a browser — visit it manually: $url"
}
# pause "msg" — wait for the human to confirm they've done the manual part.
pause() {
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
read -r _ || true
}
# confirm "question" — y/N gate; returns success on yes.
confirm() {
local reply=""
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
read -r reply || true
[[ "$reply" =~ ^[Yy] ]]
}
# _existing KEY — current value of KEY in ENV_FILE, if any.
_existing() {
[[ -f "$ENV_FILE" ]] || return 1
local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
printf '%s' "${line#*=}"
}
# ask KEY "Prompt" — read a value into $KEY. Offers the existing .env value as
# a default on re-runs (Enter keeps it). Visible input (non-secret).
ask() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -r input || true
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# ask_secret KEY "Prompt" — like ask, but input is hidden.
ask_secret() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -rs input || true
printf '\n'
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# write_env KEY VALUE — upsert KEY=VALUE into ENV_FILE (creates it; replaces
# any existing line). Idempotent.
write_env() {
local key="$1" value="$2" tmp
touch "$ENV_FILE"
tmp=$(mktemp)
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
printf '%s=%s\n' "$key" "$value" >> "$tmp"
mv "$tmp" "$ENV_FILE"
WRITTEN_ENV+=("$key")
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
}
# set_secret NAME VALUE — set a GitHub Actions repo secret via gh. Falls back
# to a warning (and records it) if gh is unavailable or unauthenticated.
set_secret() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
WRITTEN_SECRET+=("$name")
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
warn "skipped GitHub secret $name — gh not ready; set it later"
}
# set_var NAME VALUE — set a GitHub Actions repo variable (non-secret).
set_var() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub variable $name")
warn "skipped GitHub variable $name — gh not ready; set it later"
}
# finish — clear, then a closing summary of everything configured.
finish() {
_clear
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
if (( ${#SKIPPED[@]} )); then
printf '\n'; warn "still to do by hand:"
for s in "${SKIPPED[@]}"; do note " - $s"; done
fi
printf '\n'
}
# ──────────────────────────────────────────────────────────────────────────
# STAGES — author this section. One stage() per step the human takes.
# Replace the example below. Set the two totals to match the stages you write.
# ──────────────────────────────────────────────────────────────────────────
TOTAL_STAGES=7
TOTAL_MINUTES=35
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLAN_ROOT="${CPH_SILO_PLAN_ROOT:-$HOME/.cph-silo-plans}"
umask 077
require_value() {
local key="$1" value="$2"
[[ -n "$value" ]] || { warn "$key is required"; exit 1; }
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || {
warn "$key must be a single line"
exit 1
}
}
seed_default() {
local key="$1" value="$2"
_existing "$key" >/dev/null 2>&1 || write_env "$key" "$value"
}
capture() {
local key="$1" prompt="$2"
ask "$key" "$prompt"
require_value "$key" "${!key}"
write_env "$key" "${!key}"
}
capture_secret() {
local key="$1" prompt="$2"
ask_secret "$key" "$prompt"
require_value "$key" "${!key}"
write_env "$key" "${!key}"
}
allocate_host_slot() {
local allocation local_reserved="" answers_file reserved_port
for answers_file in "$PLAN_ROOT"/*/answers.env; do
[ -f "$answers_file" ] || continue
reserved_port="$(sed -n 's/^HUB_PORT=//p' "$answers_file" | tail -n1)"
[[ "$reserved_port" =~ ^[0-9]+$ ]] || continue
local_reserved="${local_reserved:+$local_reserved,}$reserved_port"
done
allocation="$(ssh \
-i "$DEPLOY_SSH_KEY" \
-p "$DEPLOY_SSH_PORT" \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
"$DEPLOY_USER@$DEPLOY_HOST" \
bash -s -- "$local_reserved" <<'REMOTE'
set -euo pipefail
local_reserved=",${1:-},"
for candidate in $(seq 8788 8999); do
reserved=false
if [[ "$local_reserved" == *",$candidate,"* ]]; then
continue
fi
for env_file in /srv/curriculum-project-hub/.secrets/*/platform.env; do
[ -f "$env_file" ] || continue
if [ "$(sed -n "s/^PORT=//p" "$env_file")" = "$candidate" ]; then
reserved=true
break
fi
done
workspace="/w/$candidate"
if [ "$reserved" = false ] && ! ss -H -ltn "sport = :$candidate" | grep -q . && [ ! -e "$workspace" ]; then
printf "%s %s\n" "$candidate" "$workspace"
exit 0
fi
done
echo "no free Alpha Silo slot in 8788..8999" >&2
exit 1
REMOTE
)"
read -r HUB_PORT WORKSPACE_ROOT <<<"$allocation"
require_value HUB_PORT "$HUB_PORT"
require_value WORKSPACE_ROOT "$WORKSPACE_ROOT"
write_env HUB_PORT "$HUB_PORT"
write_env WORKSPACE_ROOT "$WORKSPACE_ROOT"
}
banner "New Alpha Silo"
stage "Organization identity and private plan" 3
say "One run creates one Organization's private deployment bundle."
ask ORGANIZATION_SLUG "Organization slug (lowercase, max 24 chars; e.g. school-a):"
require_value ORGANIZATION_SLUG "$ORGANIZATION_SLUG"
[[ "$ORGANIZATION_SLUG" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]] || {
warn "invalid Organization slug"
exit 1
}
INSTANCE_ID="$ORGANIZATION_SLUG"
ORGANIZATION_ID="$ORGANIZATION_SLUG"
OUTPUT_DIR="$PLAN_ROOT/$INSTANCE_ID"
mkdir -p "$OUTPUT_DIR"
chmod 0700 "$OUTPUT_DIR"
ENV_FILE="$OUTPUT_DIR/answers.env"
touch "$ENV_FILE"
chmod 0600 "$ENV_FILE"
write_env INSTANCE_ID "$INSTANCE_ID"
write_env ORGANIZATION_ID "$ORGANIZATION_ID"
write_env ORGANIZATION_SLUG "$ORGANIZATION_SLUG"
capture ORGANIZATION_NAME "Organization display name:"
stage "Host, release, and isolation" 5
say "The Alpha host is platform-managed. Port and short workspace path are allocated from live host state."
DEPLOY_HOST="${CPH_ALPHA_HOST:-39.107.254.4}"
DEPLOY_USER="${CPH_ALPHA_DEPLOY_USER:-root}"
DEPLOY_SSH_PORT="${CPH_ALPHA_SSH_PORT:-22}"
write_env DEPLOY_HOST "$DEPLOY_HOST"
write_env DEPLOY_USER "$DEPLOY_USER"
write_env DEPLOY_SSH_PORT "$DEPLOY_SSH_PORT"
seed_default DEPLOY_BASE "/srv/curriculum-project-hub"
seed_default DEPLOY_SSH_KEY "$HOME/.ssh/id_ed25519"
write_env RELEASE_ID "v$(node -p 'require(process.argv[1]).version' "$REPO_ROOT/hub/package.json")"
seed_default MEMORY_MAX "16G"
seed_default CPU_QUOTA "400%"
seed_default TASKS_MAX "512"
seed_default CPH_BIN "/usr/local/bin/cph"
note "Managed Alpha host: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_SSH_PORT"
DEPLOY_SSH_KEY="$(_existing DEPLOY_SSH_KEY)"
MEMORY_MAX="$(_existing MEMORY_MAX)"
CPU_QUOTA="$(_existing CPU_QUOTA)"
TASKS_MAX="$(_existing TASKS_MAX)"
[ -f "$DEPLOY_SSH_KEY" ] || { warn "managed SSH key is missing: $DEPLOY_SSH_KEY"; exit 1; }
if [[ "$(_existing HUB_PORT || true)" =~ ^(878[8-9]|87[9][0-9]|8[89][0-9]{2})$ ]] && \
[ "$(_existing WORKSPACE_ROOT || true)" = "/w/$(_existing HUB_PORT)" ]; then
HUB_PORT="$(_existing HUB_PORT)"
WORKSPACE_ROOT="$(_existing WORKSPACE_ROOT)"
note "Keeping allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT"
else
say "Checking existing Silo environments, listening sockets, and workspace paths..."
allocate_host_slot
note "Allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT"
fi
note "Platform ceilings: MemoryMax=$MEMORY_MAX, CPUQuota=$CPU_QUOTA, TasksMax=$TASKS_MAX"
stage "Dedicated PostgreSQL database" 4
say "A PostgreSQL server may be shared, but this Silo gets a distinct login role and database."
seed_default DATABASE_HOST "127.0.0.1"
seed_default DATABASE_PORT "5432"
seed_default DATABASE_NAME "cph_${INSTANCE_ID//-/_}"
seed_default DATABASE_USER "cph_${INSTANCE_ID//-/_}"
DATABASE_NAME="$(_existing DATABASE_NAME)"
if ! _existing DATABASE_PASSWORD >/dev/null 2>&1; then
DATABASE_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')"
write_env DATABASE_PASSWORD "$DATABASE_PASSWORD"
fi
note "The generated OPERATE.md uses an interactive/protected SQL path; the password is never put in a command argument."
stage "Public URL and Feishu app" 9
open_url "https://open.feishu.cn/app"
say "Create or open the Organization's own app. Copy credentials from Credentials & Basic Info."
PUBLIC_BASE_URL="https://${ORGANIZATION_SLUG}.${CPH_ALPHA_BASE_DOMAIN:-educraft.paradigm-edu.net}"
write_env PUBLIC_BASE_URL "$PUBLIC_BASE_URL"
note "Platform-assigned public URL: $PUBLIC_BASE_URL"
capture FEISHU_APP_ID "Feishu App ID:"
capture_secret FEISHU_APP_SECRET "Feishu App Secret:"
say "Resolving the bot Open ID from Feishu..."
FEISHU_BOT_OPEN_ID="$(printf '%s\0%s\0' "$FEISHU_APP_ID" "$FEISHU_APP_SECRET" | node "$SCRIPT_DIR/resolve_feishu_bot.mjs")"
write_env FEISHU_BOT_OPEN_ID "$FEISHU_BOT_OPEN_ID"
note "Resolved bot identity: $FEISHU_BOT_OPEN_ID"
open_url "https://open.feishu.cn/document/server-docs/contact-v3/user/get"
step "In the user/get page, click the user_id value picker, select the first OWNER, and copy the returned open_id."
capture OWNER_OPEN_ID "OWNER Open ID (ou_...):"
capture OWNER_DISPLAY_NAME "OWNER display name:"
write_env OWNER_UNION_ID ""
say "The exact redirect URL and acceptance steps will be written to OPERATE.md."
stage "Provider and Alpha limits" 5
say "Use a provider credential exclusive to this Organization. Host proxy setup is a separate prerequisite."
seed_default PROVIDER_ID "openrouter"
seed_default PROVIDER_BASE_URL "https://openrouter.ai/api"
seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5"
seed_default DEFAULT_ROLE_ID "draft"
seed_default DEFAULT_ROLE_LABEL "智能助手"
seed_default MAX_TURNS "25"
seed_default MAX_CONCURRENT_RUNS "4"
seed_default MAX_RUN_SECONDS "900"
seed_default HTTP_BODY_LIMIT_BYTES "1048576"
seed_default MAX_FILES_PER_MESSAGE "8"
seed_default MAX_FILE_BYTES "26214400"
seed_default HTTP_REQUESTS_PER_MINUTE "120"
seed_default FEISHU_EVENTS_PER_MINUTE "120"
capture_secret PROVIDER_AUTH_TOKEN "Provider auth token:"
for key in PROVIDER_ID PROVIDER_BASE_URL DEFAULT_MODEL DEFAULT_ROLE_ID DEFAULT_ROLE_LABEL \
MAX_TURNS MAX_CONCURRENT_RUNS MAX_RUN_SECONDS HTTP_BODY_LIMIT_BYTES \
MAX_FILES_PER_MESSAGE MAX_FILE_BYTES HTTP_REQUESTS_PER_MINUTE FEISHU_EVENTS_PER_MINUTE; do
printf -v "$key" '%s' "$(_existing "$key")"
done
write_env APPROVED_SKILLS "outline,lesson-project,data-processing-spec,typst"
note "Platform runtime: OpenRouter, concurrency 4, default education role, curated skills."
if ! _existing HUB_SESSION_SECRET >/dev/null 2>&1; then
command -v openssl >/dev/null 2>&1 || { warn "openssl is required"; exit 1; }
HUB_SESSION_SECRET="$(openssl rand -hex 32)"
write_env HUB_SESSION_SECRET "$HUB_SESSION_SECRET"
fi
stage "Render the private deployment bundle" 2
say "This renders platform.env, bootstrap.json, deploy.env, nginx.conf and OPERATE.md."
command -v node >/dev/null 2>&1 || { warn "Node.js is required to render safely"; exit 1; }
node "$SCRIPT_DIR/render_new_silo_bundle.mjs" "$ENV_FILE" "$OUTPUT_DIR"
chmod 0700 "$OUTPUT_DIR"
chmod 0600 "$OUTPUT_DIR"/*
say "Bundle: $OUTPUT_DIR"
warn "It contains database, Feishu, provider and session secrets. Never commit or paste it."
stage "Operator handoff and gates" 7
say "The deployment package is an internal retry/audit checkpoint; you do not operate it manually."
step "Target: $PUBLIC_BASE_URL$DEPLOY_HOST:$HUB_PORT"
step "Resources: database $DATABASE_NAME, workspace $WORKSPACE_ROOT, service cph-hub-$INSTANCE_ID"
warn "Confirmation will create server, database, TLS, and runtime state."
if confirm "Deploy this Organization now?"; then
bash "$SCRIPT_DIR/apply_new_silo.sh" "$OUTPUT_DIR"
else
warn "deployment skipped; rerun the wizard later and keep existing answers"
fi
finish
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const [answersPath, outputPath] = process.argv.slice(2);
if (!answersPath || !outputPath) {
throw new Error("usage: render_new_silo_bundle.mjs ANSWERS_ENV OUTPUT_DIR");
}
function parseAnswers(source) {
const values = new Map();
for (const [index, line] of source.split("\n").entries()) {
if (!line || line.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator < 1) throw new Error(`invalid answers line ${index + 1}`);
const key = line.slice(0, separator);
const value = line.slice(separator + 1);
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
throw new Error(`invalid answers key on line ${index + 1}: ${key}`);
}
if (value.includes("\r") || value.includes("\n")) {
throw new Error(`multiline value is not supported: ${key}`);
}
values.set(key, value);
}
return values;
}
function required(values, key) {
const value = values.get(key);
if (!value) throw new Error(`missing required answer: ${key}`);
return value;
}
function optional(values, key, fallback = "") {
return values.get(key) || fallback;
}
function assertMatch(label, value, pattern) {
if (!pattern.test(value)) throw new Error(`invalid ${label}: ${value}`);
}
function envLine(key, value) {
if (/[\r\n]/.test(value)) throw new Error(`unsafe newline in ${key}`);
return `${key}=${value}`;
}
const answers = parseAnswers(await readFile(resolve(answersPath), "utf8"));
const instanceId = required(answers, "INSTANCE_ID");
const orgId = required(answers, "ORGANIZATION_ID");
const orgSlug = required(answers, "ORGANIZATION_SLUG");
const port = required(answers, "HUB_PORT");
const workspaceRoot = required(answers, "WORKSPACE_ROOT");
const publicBaseUrl = required(answers, "PUBLIC_BASE_URL").replace(/\/$/, "");
const domain = new URL(publicBaseUrl).hostname;
const databasePassword = required(answers, "DATABASE_PASSWORD");
const databaseUrl = `postgresql://${encodeURIComponent(required(answers, "DATABASE_USER"))}:${encodeURIComponent(databasePassword)}@${required(answers, "DATABASE_HOST")}:${required(answers, "DATABASE_PORT")}/${encodeURIComponent(required(answers, "DATABASE_NAME"))}`;
assertMatch("INSTANCE_ID", instanceId, /^[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?$/);
assertMatch("organization slug", orgSlug, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
assertMatch("port", port, /^[1-9][0-9]{1,4}$/);
assertMatch("database name", required(answers, "DATABASE_NAME"), /^[a-z_][a-z0-9_]*$/);
assertMatch("database user", required(answers, "DATABASE_USER"), /^[a-z_][a-z0-9_]*$/);
assertMatch("default role id", required(answers, "DEFAULT_ROLE_ID"), /^[a-z][a-z0-9-]*$/);
assertMatch("release id", required(answers, "RELEASE_ID"), /^[A-Za-z0-9._-]+$/);
if (!publicBaseUrl.startsWith("https://")) throw new Error("PUBLIC_BASE_URL must use https");
for (const [label, value] of [
["WORKSPACE_ROOT", workspaceRoot],
["DEPLOY_BASE", required(answers, "DEPLOY_BASE")],
["DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")],
["CPH_BIN", required(answers, "CPH_BIN")],
]) {
if (!value.startsWith("/") || /\s/.test(value)) {
throw new Error(`${label} must be an absolute path without whitespace: ${value}`);
}
}
if (Number(port) > 65535) throw new Error(`invalid port: ${port}`);
if (Buffer.byteLength(workspaceRoot) > 16) {
throw new Error(`WORKSPACE_ROOT exceeds the 16-byte sandbox socket limit: ${workspaceRoot}`);
}
const outputDir = resolve(outputPath);
await mkdir(outputDir, { recursive: true, mode: 0o700 });
await chmod(outputDir, 0o700);
const base = required(answers, "DEPLOY_BASE");
const release = required(answers, "RELEASE_ID");
const releaseHub = `${base}/releases/${release}/hub`;
const secretDir = `${base}/.secrets/${instanceId}`;
const envPath = `${secretDir}/platform.env`;
const keyringPath = `${secretDir}/secret-keyring.json`;
const unit = `cph-hub-${instanceId}.service`;
const platformEnv = [
"# Generated by hub/deploy/new_silo.sh. Install root:root mode 0600.",
envLine("NODE_ENV", "production"),
envLine("DATABASE_URL", databaseUrl),
envLine("HUB_SILO_ORGANIZATION_ID", orgId),
envLine("HUB_SYSTEMD_UNIT", unit),
envLine("CPH_BIN", required(answers, "CPH_BIN")),
envLine("HOST", "127.0.0.1"),
envLine("PORT", port),
envLine("HUB_PROJECT_WORKSPACE_ROOT", workspaceRoot),
envLine("HUB_PUBLIC_BASE_URL", publicBaseUrl),
envLine("HUB_SESSION_SECRET", required(answers, "HUB_SESSION_SECRET")),
envLine("HUB_AGENT_MAX_TURNS", required(answers, "MAX_TURNS")),
envLine("HUB_AGENT_MAX_CONCURRENT_RUNS", required(answers, "MAX_CONCURRENT_RUNS")),
envLine("HUB_AGENT_MAX_RUN_SECONDS", required(answers, "MAX_RUN_SECONDS")),
envLine("HUB_HTTP_BODY_LIMIT_BYTES", required(answers, "HTTP_BODY_LIMIT_BYTES")),
envLine("HUB_MAX_FILES_PER_MESSAGE", required(answers, "MAX_FILES_PER_MESSAGE")),
envLine("HUB_MAX_FILE_BYTES", required(answers, "MAX_FILE_BYTES")),
envLine("HUB_HTTP_REQUESTS_PER_MINUTE", required(answers, "HTTP_REQUESTS_PER_MINUTE")),
envLine("HUB_FEISHU_EVENTS_PER_MINUTE", required(answers, "FEISHU_EVENTS_PER_MINUTE")),
envLine("HUB_FEISHU_LISTENER_ENABLED", "true"),
envLine("HTTP_PROXY", "http://127.0.0.1:7890"),
envLine("HTTPS_PROXY", "http://127.0.0.1:7890"),
envLine("ALL_PROXY", "socks5h://127.0.0.1:7890"),
envLine("NO_PROXY", "127.0.0.1,localhost,::1"),
envLine("NODE_USE_ENV_PROXY", "1"),
envLine("ANTHROPIC_DEFAULT_SONNET_MODEL", "anthropic/claude-sonnet-5"),
envLine("CPH_SANDBOX_EXTRA_DENY_READ", `${envPath}:${keyringPath}`),
"",
].join("\n");
const bootstrap = {
organization: {
id: orgId,
slug: orgSlug,
name: required(answers, "ORGANIZATION_NAME"),
},
owner: {
openId: required(answers, "OWNER_OPEN_ID"),
displayName: required(answers, "OWNER_DISPLAY_NAME"),
},
feishu: {
appId: required(answers, "FEISHU_APP_ID"),
appSecret: required(answers, "FEISHU_APP_SECRET"),
botOpenId: required(answers, "FEISHU_BOT_OPEN_ID"),
},
provider: {
providerId: required(answers, "PROVIDER_ID"),
baseUrl: required(answers, "PROVIDER_BASE_URL"),
authToken: required(answers, "PROVIDER_AUTH_TOKEN"),
},
teams: [],
};
const ownerUnionId = optional(answers, "OWNER_UNION_ID");
if (ownerUnionId) bootstrap.owner.unionId = ownerUnionId;
const deployEnv = [
"# Source this file locally before deploy_platform.sh (contains no app/provider secrets).",
envLine("PLATFORM_DEPLOY_HOST", required(answers, "DEPLOY_HOST")),
envLine("PLATFORM_DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")),
envLine("PLATFORM_DEPLOY_USER", required(answers, "DEPLOY_USER")),
envLine("PLATFORM_DEPLOY_PORT", required(answers, "DEPLOY_SSH_PORT")),
envLine("PLATFORM_DEPLOY_HUB_PORT", port),
envLine("PLATFORM_DEPLOY_BASE", base),
envLine("PLATFORM_DEPLOY_RELEASE", release),
envLine("PLATFORM_DEPLOY_INSTANCE", instanceId),
envLine("PLATFORM_DEPLOY_WORKSPACE_ROOT", workspaceRoot),
envLine("PLATFORM_DEPLOY_MEMORY_MAX", required(answers, "MEMORY_MAX")),
envLine("PLATFORM_DEPLOY_CPU_QUOTA", required(answers, "CPU_QUOTA")),
envLine("PLATFORM_DEPLOY_TASKS_MAX", required(answers, "TASKS_MAX")),
envLine("PLATFORM_DEPLOY_HEALTH_URL", `http://127.0.0.1:${port}/api/healthz`),
"",
].join("\n");
const nginx = `# Install as /etc/nginx/conf.d/${instanceId}.conf after obtaining TLS certificates.\nserver {\n listen 80;\n server_name ${domain};\n return 301 https://$host$request_uri;\n}\n\nserver {\n listen 443 ssl http2;\n server_name ${domain};\n\n ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;\n ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;\n\n location / {\n proxy_pass http://127.0.0.1:${port};\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto https;\n }\n}\n`;
const defaultRolePrompt = `你是一位教育机构智能助手,服务于学校和教培机构的日常管理与协作。
## 核心能力
**教务管理**:熟悉排课调课、班级管理、课时统计、师资调度等常见管理流程。
**教研支持**:理解课程设计、教案编写、例题/变式题、定理证明、随堂练习、阶段性测验、知识点拆解、大纲对标等教学业务概念。
**数字化操作**:熟练使用shell命令行进行文件管理、批量处理、脚本编写、文本处理(grep/sed/awk等)、自动化任务;能协助处理Markdown、Typst等文档、表格数据、格式转换等。
## 行为准则
- **实事求是**:不确定时明确说明,能力边界外的任务如实告知,不为完成目标而编造信息或勉强输出
- **简洁优先**:直接给出答案或方案,省略铺垫和客套
- **按需展开**:仅在问题复杂或用户明确要求时提供详细说明
- **结构清晰**:多步骤内容使用编号,便于飞书阅读
- **可操作性**:涉及操作时给出具体命令或步骤
保持专业、高效,像一位熟悉业务的同事。
`;
const operate = `# ${instanceId} deployment commands\n\nThis file contains no application/provider secrets. Run commands deliberately; do not source answers.env.\n\n## 1. DNS and Feishu\n\n- Point \`${domain}\` to \`${required(answers, "DEPLOY_HOST")}\`.\n- In the Feishu app, configure redirect URL: \`${publicBaseUrl}/auth/feishu/callback\`.\n- Enable the required bot/message/contact permissions and event subscription described in the customer setup document.\n\n## 2. Database (run as the PostgreSQL administrator)\n\nCreate one role and one database for this Silo. The password is in \`answers.env\`; use an interactive client or a protected SQL file, never a command-line argument.\n\n\`\`\`sql\nCREATE ROLE ${required(answers, "DATABASE_USER")} LOGIN PASSWORD '<copy DATABASE_PASSWORD from answers.env>';\nCREATE DATABASE ${required(answers, "DATABASE_NAME")} OWNER ${required(answers, "DATABASE_USER")};\n\`\`\`\n\n## 3. Publish the immutable release\n\nThe normal deploy script seeds the first-instance secrets and exits 78. That exit is expected only on this first pass.\n\n\`\`\`sh\nset -a; . ./deploy.env; set +a\nbash hub/deploy/deploy_platform.sh\n\`\`\`\n\n## 4. Install the generated secrets\n\nCopy \`platform.env\` and \`bootstrap.json\` to the server through a protected channel. On the server:\n\n\`\`\`sh\ninstall -d -o root -g root -m 0700 '${secretDir}'\ninstall -o root -g root -m 0600 platform.env '${envPath}'\ninstall -o root -g root -m 0600 bootstrap.json '/root/${instanceId}-bootstrap.json'\n# Copy ${keyringPath} to separate off-host recovery storage before continuing.\n\`\`\`\n\n## 5. Migrate, install the stopped service, and bootstrap\n\n\`\`\`sh\nset -a; . '${envPath}'; set +a\nnode '${releaseHub}/node_modules/prisma/build/index.js' migrate deploy --schema '${releaseHub}/prisma/schema.prisma'\nBASE='${base}' HUB_DIR='${releaseHub}' INSTANCE_ID='${instanceId}' WORKSPACE_ROOT='${workspaceRoot}' PORT='${port}' MEMORY_MAX='${required(answers, "MEMORY_MAX")}' CPU_QUOTA='${required(answers, "CPU_QUOTA")}' TASKS_MAX='${required(answers, "TASKS_MAX")}' bash '${releaseHub}/deploy/install_service.sh'\nnode '${releaseHub}/dist/deployment/bootstrap-silo-cli.js' --config-file '/root/${instanceId}-bootstrap.json' --keyring-file '${keyringPath}'\nrm -f '/root/${instanceId}-bootstrap.json'\n\`\`\`\n\n## 6. Runtime role and skills\n\nRuntime role/skill configuration is intentionally separate from the release. Follow NEW_SILO_RUNBOOK.md, staging each approved skill directory outside the immutable release, then use \`agent_config.sh\`. Do not silently copy a local personal skill collection.\n\n## 7. Nginx, start, and acceptance\n\nInstall \`nginx.conf\`, run \`nginx -t\`, reload Nginx, then:\n\n\`\`\`sh\nsystemctl start '${unit}'\nsystemctl is-active '${unit}'\ncurl --fail --silent --show-error 'http://127.0.0.1:${port}/api/healthz'\ncurl --fail --silent --show-error '${publicBaseUrl}/api/healthz'\njournalctl -u '${unit}' --since '-10 min' --no-pager\n\`\`\`\n\nAcceptance requires: Feishu OAuth completes, OWNER and a non-OWNER can @bot, a second group creates a separate project/session, Typst works when that skill is enabled, and restart preserves session cursor. Then take the first off-host backup.\n\n## 8. Later releases\n\nAfter the instance exists, source \`deploy.env\` with the new release id and run \`deploy_platform.sh\`. Never reuse another org's database, secret directory, workspace root, port, service identity, domain, Feishu app, or provider credential.\n`;
const selectedSkills = optional(answers, "APPROVED_SKILLS");
const roleInstructions = `
## Appendix: default runtime role
Copy \`default-role-prompt.md\` to \`/root/${instanceId}-default-role-prompt.md\`.
After installing each reviewed skill with \`agent_config.sh install-skill\`, run:
\`\`\`sh
INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} upsert-role --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --label ${JSON.stringify(required(answers, "DEFAULT_ROLE_LABEL"))} --model ${JSON.stringify(required(answers, "DEFAULT_MODEL"))} --system-prompt-file ${JSON.stringify(`/root/${instanceId}-default-role-prompt.md`)} --tools-json null
${selectedSkills ? `INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} set-role-skills --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --skills ${JSON.stringify(selectedSkills)}
` : "# No skills selected in the wizard; set-role-skills remains an explicit operator step.\n"}INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} list --organization ${JSON.stringify(orgId)}
\`\`\`
Changing a role's model, system prompt, tools or skill selection archives that
role's existing sessions by design. Finish this setup before inviting Alpha users.
`;
async function privateWrite(name, contents) {
const path = resolve(outputDir, name);
await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
await chmod(path, 0o600);
}
await privateWrite("platform.env", platformEnv);
await privateWrite("bootstrap.json", `${JSON.stringify(bootstrap, null, 2)}\n`);
await privateWrite("deploy.env", deployEnv);
await privateWrite("nginx.conf", nginx);
await privateWrite("default-role-prompt.md", defaultRolePrompt);
await privateWrite("OPERATE.md", `${operate}${roleInstructions}`);
console.log(`Rendered private Silo bundle: ${outputDir}`);
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const [appId, appSecret] = Buffer.concat(chunks).toString("utf8").split("\0");
if (!appId || !appSecret) throw new Error("Feishu App ID and App Secret are required on stdin");
const tokenResponse = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
method: "POST",
headers: { "content-type": "application/json; charset=utf-8" },
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
});
if (!tokenResponse.ok) throw new Error(`Feishu token request failed: HTTP ${tokenResponse.status}`);
const tokenPayload = await tokenResponse.json();
if (tokenPayload.code !== 0 || typeof tokenPayload.tenant_access_token !== "string") {
throw new Error(`Feishu token request failed: ${JSON.stringify(tokenPayload)}`);
}
const botResponse = await fetch("https://open.feishu.cn/open-apis/bot/v3/info", {
headers: { authorization: `Bearer ${tokenPayload.tenant_access_token}` },
});
if (!botResponse.ok) throw new Error(`Feishu bot info request failed: HTTP ${botResponse.status}`);
const botPayload = await botResponse.json();
if (botPayload.code !== 0 || typeof botPayload.bot?.open_id !== "string" || !botPayload.bot.open_id) {
throw new Error(`Feishu bot info request failed: ${JSON.stringify(botPayload)}`);
}
process.stdout.write(botPayload.bot.open_id);
+33 -50
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.7",
"version": "0.0.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.7",
"version": "0.0.27",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
@@ -131,9 +131,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -147,9 +144,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -163,9 +157,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -179,9 +170,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -247,24 +235,26 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1199,9 +1189,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1219,9 +1206,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1239,9 +1223,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1259,9 +1240,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1279,9 +1257,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1299,9 +1274,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1347,6 +1319,29 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
@@ -3157,9 +3152,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3181,9 +3173,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3205,9 +3194,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3229,9 +3215,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.7",
"version": "0.0.27",
"private": true,
"type": "module",
"engines": {
@@ -30,18 +30,21 @@
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
"scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.json && npm run admin:build",
"start": "npm run prisma:migrate && node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit",
"audit:production": "npm audit --omit=dev --audit-level=high",
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
"prisma:validate": "prisma validate --schema prisma/schema.prisma",
"prisma:migrate": "prisma migrate deploy --schema prisma/schema.prisma",
"secrets:rotate-kek": "node dist/deployment/rotate-secret-kek.js",
"agent-config": "node dist/deployment/agent-config-cli.js",
"silo:bootstrap": "node dist/deployment/bootstrap-silo-cli.js",
"silo:restore-preflight": "node dist/deployment/restore-preflight.js",
"deploy": "bash deploy/deploy_platform.sh",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"admin:dev": "npm run dev --prefix admin-web",
"admin:build": "npm run build --prefix admin-web"
}
}
@@ -0,0 +1,71 @@
-- ADR-0017: Organization-scoped runtime role bundles and content-addressed
-- skills. Composite foreign keys make cross-Organization role/skill bindings
-- structurally impossible.
CREATE TABLE "OrganizationAgentSkill" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"version" TEXT NOT NULL,
"description" TEXT,
"contentDigest" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"disabledAt" TIMESTAMP(3),
CONSTRAINT "OrganizationAgentSkill_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "OrganizationAgentRole" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"defaultModel" TEXT,
"systemPrompt" TEXT,
"tools" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"disabledAt" TIMESTAMP(3),
CONSTRAINT "OrganizationAgentRole_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "OrganizationAgentRoleSkill" (
"organizationId" TEXT NOT NULL,
"agentRoleId" TEXT NOT NULL,
"agentSkillId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrganizationAgentRoleSkill_pkey" PRIMARY KEY ("organizationId", "agentRoleId", "agentSkillId")
);
CREATE UNIQUE INDEX "OrganizationAgentSkill_organizationId_name_key" ON "OrganizationAgentSkill"("organizationId", "name");
CREATE UNIQUE INDEX "OrganizationAgentSkill_organizationId_id_key" ON "OrganizationAgentSkill"("organizationId", "id");
CREATE INDEX "OrganizationAgentSkill_organizationId_disabledAt_idx" ON "OrganizationAgentSkill"("organizationId", "disabledAt");
CREATE INDEX "OrganizationAgentSkill_contentDigest_idx" ON "OrganizationAgentSkill"("contentDigest");
CREATE UNIQUE INDEX "OrganizationAgentRole_organizationId_roleId_key" ON "OrganizationAgentRole"("organizationId", "roleId");
CREATE UNIQUE INDEX "OrganizationAgentRole_organizationId_id_key" ON "OrganizationAgentRole"("organizationId", "id");
CREATE INDEX "OrganizationAgentRole_organizationId_disabledAt_sortOrder_idx" ON "OrganizationAgentRole"("organizationId", "disabledAt", "sortOrder");
CREATE INDEX "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortOrder_idx" ON "OrganizationAgentRoleSkill"("organizationId", "agentRoleId", "sortOrder");
CREATE INDEX "OrganizationAgentRoleSkill_organizationId_agentSkillId_idx" ON "OrganizationAgentRoleSkill"("organizationId", "agentSkillId");
ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRoleSkill" ADD CONSTRAINT "OrganizationAgentRoleSkill_organizationId_agentRoleId_fkey"
FOREIGN KEY ("organizationId", "agentRoleId") REFERENCES "OrganizationAgentRole"("organizationId", "id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRoleSkill" ADD CONSTRAINT "OrganizationAgentRoleSkill_organizationId_agentSkillId_fkey"
FOREIGN KEY ("organizationId", "agentSkillId") REFERENCES "OrganizationAgentSkill"("organizationId", "id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Preserve current alpha behavior while moving role definitions into data.
INSERT INTO "OrganizationAgentRole" (
"id", "organizationId", "roleId", "label", "sortOrder", "updatedAt"
)
SELECT "id" || ':agent-role:draft', "id", 'draft', '草稿', 10, CURRENT_TIMESTAMP
FROM "Organization";
INSERT INTO "OrganizationAgentRole" (
"id", "organizationId", "roleId", "label", "sortOrder", "updatedAt"
)
SELECT "id" || ':agent-role:review', "id", 'review', '审校', 20, CURRENT_TIMESTAMP
FROM "Organization";
@@ -0,0 +1,191 @@
-- Derived project discovery projection. Project/Folder remain authoritative;
-- triggers prevent index drift through ordinary database mutations.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TYPE "FolderKind" AS ENUM ('REGULAR', 'SYSTEM_INBOX');
ALTER TABLE "Folder" ADD COLUMN "kind" "FolderKind" NOT NULL DEFAULT 'REGULAR';
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "Folder"
WHERE "parentId" IS NULL AND "name" = 'Inbox' AND "archivedAt" IS NULL
GROUP BY "organizationId" HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'cannot identify system Inbox: organization has multiple active root Inbox folders';
END IF;
END $$;
UPDATE "Folder" f SET "kind" = 'SYSTEM_INBOX'
WHERE f."parentId" IS NULL AND f."name" = 'Inbox' AND f."archivedAt" IS NULL;
INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "kind", "sortKey", "createdAt", "updatedAt")
SELECT o."id" || ':system-inbox', o."id", NULL, 'Inbox', 'SYSTEM_INBOX', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Organization" o
WHERE NOT EXISTS (
SELECT 1 FROM "Folder" f
WHERE f."organizationId" = o."id" AND f."kind" = 'SYSTEM_INBOX' AND f."archivedAt" IS NULL
);
CREATE UNIQUE INDEX "Folder_one_active_system_inbox_per_org"
ON "Folder"("organizationId") WHERE "kind" = 'SYSTEM_INBOX' AND "archivedAt" IS NULL;
CREATE OR REPLACE FUNCTION cph_protect_system_inbox() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD."kind" = 'SYSTEM_INBOX' AND (
NEW."id" IS DISTINCT FROM OLD."id" OR
NEW."organizationId" IS DISTINCT FROM OLD."organizationId" OR
NEW."kind" IS DISTINCT FROM OLD."kind" OR
NEW."name" IS DISTINCT FROM OLD."name" OR
NEW."parentId" IS DISTINCT FROM OLD."parentId" OR
NEW."archivedAt" IS DISTINCT FROM OLD."archivedAt"
) THEN
RAISE EXCEPTION 'system Inbox identity cannot be changed';
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW."kind" = 'SYSTEM_INBOX' AND (
NEW."name" <> 'Inbox' OR NEW."parentId" IS NOT NULL OR NEW."archivedAt" IS NOT NULL
) THEN
RAISE EXCEPTION 'system Inbox must be an active root folder named Inbox';
END IF;
IF TG_OP = 'DELETE' AND OLD."kind" = 'SYSTEM_INBOX' AND EXISTS (
SELECT 1 FROM "Organization" WHERE "id" = OLD."organizationId"
) THEN
RAISE EXCEPTION 'system Inbox cannot be deleted while its organization exists';
END IF;
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$;
CREATE TRIGGER cph_protect_system_inbox
BEFORE INSERT OR UPDATE OR DELETE ON "Folder"
FOR EACH ROW EXECUTE FUNCTION cph_protect_system_inbox();
ALTER TABLE "Project" ADD COLUMN "code" TEXT;
CREATE TABLE "ProjectSearchDocument" (
"projectId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"normalizedCode" TEXT NOT NULL,
"normalizedName" TEXT NOT NULL,
"breadcrumb" TEXT NOT NULL,
"normalizedBreadcrumb" TEXT NOT NULL,
"normalizedSearchText" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProjectSearchDocument_pkey" PRIMARY KEY ("projectId")
);
CREATE INDEX "ProjectSearchDocument_organizationId_idx" ON "ProjectSearchDocument"("organizationId");
CREATE INDEX "ProjectSearchDocument_normalizedName_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedName" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedCode_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedCode" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedBreadcrumb" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedSearchText" gin_trgm_ops);
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE OR REPLACE FUNCTION cph_project_search_normalize(value TEXT) RETURNS TEXT
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$
SELECT lower(regexp_replace(normalize(value, NFKC), '[[:space:]_.:/\\-]+', '', 'g'))
$$;
CREATE OR REPLACE FUNCTION cph_folder_breadcrumb(folder_id TEXT) RETURNS TEXT
LANGUAGE sql STABLE PARALLEL SAFE AS $$
WITH RECURSIVE ancestors AS (
SELECT f."id", f."parentId", f."name", f."kind", 0 AS depth
FROM "Folder" f
WHERE f."id" = folder_id
UNION ALL
SELECT parent."id", parent."parentId", parent."name", parent."kind", child.depth + 1
FROM "Folder" parent
JOIN ancestors child ON parent."id" = child."parentId"
)
SELECT CASE
WHEN count(*) = 1 AND bool_and("kind" = 'SYSTEM_INBOX') THEN '未分类'
ELSE coalesce(string_agg("name", ' / ' ORDER BY depth DESC), '')
END
FROM ancestors
$$;
CREATE OR REPLACE FUNCTION cph_project_search_code(explicit_code TEXT, project_name TEXT) RETURNS TEXT
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT CASE
WHEN explicit_code IS NOT NULL AND btrim(explicit_code) <> ''
THEN cph_project_search_normalize(explicit_code)
ELSE coalesce(substring(cph_project_search_normalize(project_name) FROM '^[a-z]+[0-9]+'), '')
END
$$;
CREATE OR REPLACE FUNCTION cph_refresh_project_search_document(target_project_id TEXT) RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO "ProjectSearchDocument" (
"projectId", "organizationId", "name", "code", "normalizedCode", "normalizedName",
"breadcrumb", "normalizedBreadcrumb", "normalizedSearchText", "updatedAt"
)
SELECT p."id", p."organizationId", p."name", p."code",
cph_project_search_code(p."code", p."name"),
cph_project_search_normalize(p."name"),
cph_folder_breadcrumb(p."folderId"),
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId")),
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId") || ' ' || p."name"),
CURRENT_TIMESTAMP
FROM "Project" p WHERE p."id" = target_project_id
ON CONFLICT ("projectId") DO UPDATE SET
"organizationId" = EXCLUDED."organizationId",
"name" = EXCLUDED."name",
"code" = EXCLUDED."code",
"normalizedCode" = EXCLUDED."normalizedCode",
"normalizedName" = EXCLUDED."normalizedName",
"breadcrumb" = EXCLUDED."breadcrumb",
"normalizedBreadcrumb" = EXCLUDED."normalizedBreadcrumb",
"normalizedSearchText" = EXCLUDED."normalizedSearchText",
"updatedAt" = CURRENT_TIMESTAMP;
END;
$$;
CREATE OR REPLACE FUNCTION cph_project_search_project_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM cph_refresh_project_search_document(NEW."id");
RETURN NEW;
END;
$$;
CREATE TRIGGER cph_project_search_project_changed
AFTER INSERT OR UPDATE OF "name", "code", "folderId", "organizationId", "archivedAt" ON "Project"
FOR EACH ROW EXECUTE FUNCTION cph_project_search_project_trigger();
CREATE OR REPLACE FUNCTION cph_project_search_folder_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE project_id TEXT;
BEGIN
FOR project_id IN
WITH RECURSIVE descendants AS (
SELECT NEW."id"
UNION ALL
SELECT child."id" FROM "Folder" child
JOIN descendants parent ON child."parentId" = parent."id"
)
SELECT p."id" FROM "Project" p
WHERE p."folderId" IN (SELECT "id" FROM descendants)
LOOP
PERFORM cph_refresh_project_search_document(project_id);
END LOOP;
RETURN NEW;
END;
$$;
CREATE TRIGGER cph_project_search_folder_changed
AFTER UPDATE OF "name", "kind", "parentId", "archivedAt" ON "Folder"
FOR EACH ROW EXECUTE FUNCTION cph_project_search_folder_trigger();
SELECT cph_refresh_project_search_document("id") FROM "Project";
@@ -0,0 +1,65 @@
-- ADR-0017: roles are selected through the project-group control plane, not
-- through slash-command names. Existing alpha Organizations keep draft as
-- their configured default during migration; runtime code no longer hard-codes it.
ALTER TABLE "OrganizationAgentRole"
ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
-- An Organization without any role was valid in the previous schema (the
-- runtime failed closed later). Preserve the old alpha baseline so every
-- existing binding can acquire a selected role during this migration.
INSERT INTO "OrganizationAgentRole" (
"id", "organizationId", "roleId", "label", "sortOrder", "isDefault", "updatedAt"
)
SELECT organization."id" || ':agent-role:draft', organization."id", 'draft', '草稿', 10, true, CURRENT_TIMESTAMP
FROM "Organization" organization
WHERE NOT EXISTS (
SELECT 1 FROM "OrganizationAgentRole" role
WHERE role."organizationId" = organization."id" AND role."disabledAt" IS NULL
);
WITH ranked AS (
SELECT "id", row_number() OVER (
PARTITION BY "organizationId"
ORDER BY CASE WHEN "roleId" = 'draft' THEN 0 ELSE 1 END, "sortOrder", "roleId", "id"
) AS position
FROM "OrganizationAgentRole"
WHERE "disabledAt" IS NULL
)
UPDATE "OrganizationAgentRole" role
SET "isDefault" = (ranked.position = 1)
FROM ranked
WHERE role."id" = ranked."id";
CREATE UNIQUE INDEX "OrganizationAgentRole_one_active_default_per_org"
ON "OrganizationAgentRole"("organizationId")
WHERE "isDefault" = true AND "disabledAt" IS NULL;
ALTER TABLE "ProjectGroupBinding"
ADD COLUMN "selectedAgentRoleId" TEXT;
UPDATE "ProjectGroupBinding" binding
SET "selectedAgentRoleId" = role."id"
FROM "Project" project
JOIN "OrganizationAgentRole" role
ON role."organizationId" = project."organizationId"
AND role."isDefault" = true
AND role."disabledAt" IS NULL
WHERE binding."projectId" = project."id";
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "ProjectGroupBinding" WHERE "selectedAgentRoleId" IS NULL) THEN
RAISE EXCEPTION 'cannot migrate project-group bindings without an active default Agent role';
END IF;
END $$;
ALTER TABLE "ProjectGroupBinding"
ALTER COLUMN "selectedAgentRoleId" SET NOT NULL;
CREATE INDEX "ProjectGroupBinding_selectedAgentRoleId_idx"
ON "ProjectGroupBinding"("selectedAgentRoleId");
ALTER TABLE "ProjectGroupBinding"
ADD CONSTRAINT "ProjectGroupBinding_selectedAgentRoleId_fkey"
FOREIGN KEY ("selectedAgentRoleId") REFERENCES "OrganizationAgentRole"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,41 @@
-- ADR-0017 / ADR-0020: enforce the selected role's Organization at the
-- database boundary. A role primary key alone cannot prove tenant scope.
ALTER TABLE "ProjectGroupBinding"
ADD COLUMN "organizationId" TEXT;
UPDATE "ProjectGroupBinding" binding
SET "organizationId" = project."organizationId"
FROM "Project" project
WHERE project."id" = binding."projectId";
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "ProjectGroupBinding" WHERE "organizationId" IS NULL) THEN
RAISE EXCEPTION 'cannot tenant-scope project-group binding without a project Organization';
END IF;
END $$;
ALTER TABLE "ProjectGroupBinding"
ALTER COLUMN "organizationId" SET NOT NULL;
CREATE UNIQUE INDEX "Project_organizationId_id_key"
ON "Project"("organizationId", "id");
ALTER TABLE "ProjectGroupBinding"
DROP CONSTRAINT "ProjectGroupBinding_projectId_fkey",
DROP CONSTRAINT "ProjectGroupBinding_selectedAgentRoleId_fkey";
ALTER TABLE "ProjectGroupBinding"
ADD CONSTRAINT "ProjectGroupBinding_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "ProjectGroupBinding_organizationId_projectId_fkey"
FOREIGN KEY ("organizationId", "projectId") REFERENCES "Project"("organizationId", "id")
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "ProjectGroupBinding_organizationId_selectedAgentRoleId_fkey"
FOREIGN KEY ("organizationId", "selectedAgentRoleId")
REFERENCES "OrganizationAgentRole"("organizationId", "id")
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "ProjectGroupBinding_organizationId_idx"
ON "ProjectGroupBinding"("organizationId");
@@ -0,0 +1,31 @@
-- ADR-0017: once an Organization starts configuring roles, every committed
-- state must contain exactly one active default. The deferred trigger permits
-- an atomic default switch while rejecting zero-default transitions.
CREATE FUNCTION cph_enforce_agent_role_default() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
target_organization_id TEXT := COALESCE(NEW."organizationId", OLD."organizationId");
active_default_count INTEGER;
BEGIN
IF NOT EXISTS (SELECT 1 FROM "Organization" WHERE "id" = target_organization_id) THEN
RETURN NULL;
END IF;
SELECT count(*) INTO active_default_count
FROM "OrganizationAgentRole"
WHERE "organizationId" = target_organization_id
AND "isDefault" = true
AND "disabledAt" IS NULL;
IF active_default_count <> 1 THEN
RAISE EXCEPTION 'Organization % must have exactly one active default Agent role; found %',
target_organization_id, active_default_count;
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER "OrganizationAgentRole_exactly_one_active_default"
AFTER INSERT OR UPDATE OR DELETE ON "OrganizationAgentRole"
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION cph_enforce_agent_role_default();
@@ -0,0 +1,30 @@
-- Organization-owned role configuration cannot be re-parented. Besides being
-- a tenant boundary, immutability ensures the deferred default-role invariant
-- checks the same Organization before and after an update.
CREATE OR REPLACE FUNCTION cph_enforce_agent_role_default() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
target_organization_id TEXT := COALESCE(NEW."organizationId", OLD."organizationId");
active_default_count INTEGER;
BEGIN
IF TG_OP = 'UPDATE' AND NEW."organizationId" <> OLD."organizationId" THEN
RAISE EXCEPTION 'OrganizationAgentRole.organizationId is immutable';
END IF;
IF NOT EXISTS (SELECT 1 FROM "Organization" WHERE "id" = target_organization_id) THEN
RETURN NULL;
END IF;
SELECT count(*) INTO active_default_count
FROM "OrganizationAgentRole"
WHERE "organizationId" = target_organization_id
AND "isDefault" = true
AND "disabledAt" IS NULL;
IF active_default_count <> 1 THEN
RAISE EXCEPTION 'Organization % must have exactly one active default Agent role; found %',
target_organization_id, active_default_count;
END IF;
RETURN NULL;
END;
$$;
@@ -0,0 +1,25 @@
-- ADR-0017's default-role function is total over Organizations. Enforce the
-- other side of the invariant when an Organization itself is created.
CREATE FUNCTION cph_enforce_organization_default_role() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
active_default_count INTEGER;
BEGIN
SELECT count(*) INTO active_default_count
FROM "OrganizationAgentRole"
WHERE "organizationId" = NEW."id"
AND "isDefault" = true
AND "disabledAt" IS NULL;
IF active_default_count <> 1 THEN
RAISE EXCEPTION 'Organization % must have exactly one active default Agent role; found %',
NEW."id", active_default_count;
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER "Organization_requires_active_default_role"
AFTER INSERT ON "Organization"
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION cph_enforce_organization_default_role();
@@ -0,0 +1,20 @@
-- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER}
-- model: the platform administration control plane is a separate identity/session/
-- audit surface (see `Spec.System.PlatformAdministration`), intentionally not built
-- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime
-- reader — no guard, route, or service queries it for an authorization decision —
-- and ADR-0023 requires it to be migrated/replaced before the platform panel ships.
-- Drop the table, the `User.platformRoles` relation, and the enum.
-- DropForeignKey
ALTER TABLE "PlatformRoleAssignment" DROP CONSTRAINT "PlatformRoleAssignment_userId_fkey";
-- DropIndex
DROP INDEX IF EXISTS "PlatformRoleAssignment_userId_revokedAt_idx";
DROP INDEX IF EXISTS "PlatformRoleAssignment_role_revokedAt_idx";
-- DropTable
DROP TABLE IF EXISTS "PlatformRoleAssignment";
-- DropEnum
DROP TYPE IF EXISTS "PlatformRole";
@@ -0,0 +1,18 @@
-- ADR-0022 layered capacity policy. Org admins may set per-dimension lower
-- `organizationLimit` overrides; platform ceilings come from deployment config
-- (see `src/capacity/ceilings.ts`). `limits` is a JSON map of
-- CapacityDimension → number (only the set dimensions); service enforces
-- `LayeredLimit.Valid` (org limit ≤ platform ceiling).
-- CreateTable
CREATE TABLE "OrganizationCapacityPolicy" (
"organizationId" TEXT NOT NULL,
"limits" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationCapacityPolicy_pkey" PRIMARY KEY ("organizationId")
);
-- AddForeignKey
ALTER TABLE "OrganizationCapacityPolicy" ADD CONSTRAINT "OrganizationCapacityPolicy_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+172 -70
View File
@@ -37,13 +37,18 @@ model Organization {
memberships OrganizationMembership[]
projectSettings OrganizationProjectSettings?
capacityPolicy OrganizationCapacityPolicy?
folders Folder[]
projects Project[]
teams Team[]
externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[]
feishuApplicationConnection OrganizationFeishuApplicationConnection?
auditEntries AuditEntry[] @relation("organizationAudit")
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
@@index([status])
}
@@ -54,8 +59,9 @@ enum OrganizationStatus {
ARCHIVED
}
/// Org-scoped platform role. Distinct from project PermissionRole and from
/// global PlatformRoleAssignment, which is reserved for SaaS/platform control.
/// Org-scoped membership role. Distinct from project PermissionRole and from
/// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
/// which is a separate control plane not modeled in alpha (ADR-0025).
model OrganizationMembership {
id String @id @default(cuid())
organizationId String
@@ -78,6 +84,72 @@ enum OrganizationMemberRole {
MEMBER
}
/// Organization-scoped, content-addressed Agent skill registration. The DB is
/// the runtime registry; `contentDigest` selects an immutable directory below
/// the platform-controlled skill store and is never interpreted as a path.
model OrganizationAgentSkill {
id String @id @default(cuid())
organizationId String
name String
version String
description String?
contentDigest String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name])
@@unique([organizationId, id])
@@index([organizationId, disabledAt])
@@index([contentDigest])
}
/// ADR-0017 runtime role bundle. Roles are Organization-owned data rather than
/// a code enum: model, system prompt, tool allowlist and skill selection change
/// without a Hub release or process restart.
model OrganizationAgentRole {
id String @id @default(cuid())
organizationId String
roleId String
label String
defaultModel String?
systemPrompt String?
tools Json?
sortOrder Int @default(0)
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId])
@@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder])
}
/// Same-Organization join enforced by both composite foreign keys. `sortOrder`
/// gives stable skill listing and prompt discovery order for a role bundle.
model OrganizationAgentRoleSkill {
organizationId String
agentRoleId String
agentSkillId String
sortOrder Int @default(0)
createdAt DateTime @default(now())
role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade)
skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade)
@@id([organizationId, agentRoleId, agentSkillId])
@@index([organizationId, agentRoleId, sortOrder])
@@index([organizationId, agentSkillId])
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
@@ -89,6 +161,20 @@ model OrganizationProjectSettings {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
/// ADR-0022 layered capacity policy. Org admins may set per-dimension lower
/// limits; `limits` is a JSON map of CapacityDimension → number (only the set
/// ones). Each set value must be ≤ the platform ceiling for that dimension
/// (validated in service; spec `LayeredLimit.Valid`). Dimensions with no entry
/// fall back to the platform ceiling (`LayeredLimit.effective`).
model OrganizationCapacityPolicy {
organizationId String @id
limits Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
/// A person known to the Hub. `feishuOpenId` remains a legacy compatibility
/// key; new customer-app identities live in FeishuUserIdentity and store a
/// connection-scoped opaque USER principal here instead of a raw open_id.
@@ -100,18 +186,17 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
platformRoles PlatformRoleAssignment[]
organizationMemberships OrganizationMembership[]
createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
feishuIdentities FeishuUserIdentity[]
}
@@ -130,7 +215,7 @@ model OrganizationFeishuApplicationConnection {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
secretVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersions")
activeSecretVersion FeishuApplicationCredentialVersion? @relation("activeFeishuCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
userIdentities FeishuUserIdentity[]
@@ -162,19 +247,19 @@ model FeishuUserIdentity {
/// ADR-0024: all Feishu app material, including provider-local app/bot ids, is
/// inside one immutable authenticated envelope version.
model FeishuApplicationCredentialVersion {
id String @id @default(cuid())
id String @id @default(cuid())
connectionId String
version Int
envelopeVersion Int @default(1)
envelopeVersion Int @default(1)
keyId String
envelope Json
createdByUserId String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
retiredAt DateTime?
connection OrganizationFeishuApplicationConnection @relation("feishuCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
activeFor OrganizationFeishuApplicationConnection? @relation("activeFeishuCredentialVersion")
createdBy User? @relation("feishuCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdBy User? @relation("feishuCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([connectionId, version])
@@index([connectionId, retiredAt])
@@ -221,14 +306,14 @@ enum OrganizationConnectionStatus {
/// per immutable version. keyId/envelopeVersion are redacted rotation metadata;
/// all provider URL/token/API-key fields remain inside envelope ciphertext.
model ProviderCredentialVersion {
id String @id @default(cuid())
id String @id @default(cuid())
connectionId String
version Int
envelopeVersion Int @default(1)
envelopeVersion Int @default(1)
keyId String
envelope Json
createdByUserId String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
retiredAt DateTime?
connection OrganizationProviderConnection @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
@@ -241,26 +326,6 @@ model ProviderCredentialVersion {
@@index([createdByUserId])
}
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
/// `admin` is the only override path for force-release (spec RequiresAdmin).
model PlatformRoleAssignment {
id String @id @default(cuid())
userId String
role PlatformRole
createdAt DateTime @default(now())
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt])
@@index([role, revokedAt])
}
enum PlatformRole {
ADMIN
TEACHER
}
/// ADR-0019: typed principals for permission grants and actor resolution.
/// USER and Feishu external principals use connection-scoped opaque ids, never
/// raw provider-local ids; TEAM uses Hub Team.id.
@@ -371,14 +436,20 @@ model ExternalPrincipalMembership {
/// ADR-0021: transparent project explorer folder. Folders are org-scoped
/// navigation/aggregation nodes, not permission resources; project grants stay
/// attached to PROJECT resources.
enum FolderKind {
REGULAR
SYSTEM_INBOX
}
model Folder {
id String @id @default(cuid())
id String @id @default(cuid())
organizationId String
parentId String?
name String
sortKey String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
kind FolderKind @default(REGULAR)
sortKey String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@ -394,6 +465,7 @@ model Project {
id String @id @default(cuid())
organizationId String
folderId String?
code String?
name String
workspaceDir String
createdByUserId String?
@@ -401,39 +473,68 @@ model Project {
updatedAt DateTime @updatedAt
archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBindings ProjectGroupBinding[]
agentSessions AgentSession[]
agentRuns AgentRun[]
agentLock ProjectAgentLock?
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
searchDocument ProjectSearchDocument?
@@unique([organizationId, id])
@@index([organizationId, archivedAt])
@@index([folderId, archivedAt])
@@index([archivedAt])
}
/// Derived, rebuildable search projection for project discovery. PostgreSQL
/// triggers keep it synchronized with Project and Folder mutations; Project
/// remains the source of truth and authorization remains outside this table.
model ProjectSearchDocument {
projectId String @id
organizationId String
name String
code String?
normalizedCode String
normalizedName String
breadcrumb String
normalizedBreadcrumb String
normalizedSearchText String
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
}
/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
/// (1:1). Historical archived bindings are retained for audit; partial unique
/// indexes in migrations enforce one active binding per project and per chat.
model ProjectGroupBinding {
id String @id @default(cuid())
projectId String
chatId String
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
id String @id @default(cuid())
organizationId String
projectId String
chatId String
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
selectedAgentRoleId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
project Project @relation(fields: [organizationId, projectId], references: [organizationId, id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
selectedRole OrganizationAgentRole @relation("selectedAgentRole", fields: [organizationId, selectedAgentRoleId], references: [organizationId, id], onDelete: Restrict)
@@index([organizationId])
@@index([projectId, archivedAt])
@@index([chatId, archivedAt])
@@index([selectedAgentRoleId])
}
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
@@ -541,8 +642,9 @@ model ProjectAgentLock {
// --- Permission grants & settings (ADR-0004) ----------------------------
/// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice).
/// Distinct from PlatformRole. Force-release is admin-only, outside this
/// lattice (spec RequiresAdmin).
/// Force-release is platform-admin-only, outside this lattice (spec
/// RequiresAdmin); platform admin is a separate control plane (ADR-0023),
/// not modeled in alpha (ADR-0025).
enum PermissionRole {
READ
EDIT
@@ -631,17 +733,17 @@ model RoleTriggerGrant {
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
model AuditEntry {
id String @id @default(cuid())
runId String?
projectId String?
id String @id @default(cuid())
runId String?
projectId String?
organizationId String?
actorUserId String?
action String
metadata Json
createdAt DateTime @default(now())
actorUserId String?
action String
metadata Json
createdAt DateTime @default(now())
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull)
@@index([runId])
+76
View File
@@ -0,0 +1,76 @@
/**
* Local dev bootstrap for an Alpha Silo on Windows / non-systemd hosts.
*
* The production bootstrap-silo CLI is Linux-only (requires root uid 0,
* systemctl, root-owned files). This script calls the same
* `bootstrapAlphaSilo` invariants directly, sourcing credentials from the
* local `.env` and the dev keyring. Idempotent: re-running is a no-op once
* the Silo Organization exists.
*
* Usage: npx tsx scripts/dev-bootstrap-silo.ts
*/
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { bootstrapAlphaSilo } from "../src/deployment/bootstrap-silo.js";
import { loadLocalSecretKeyring, LocalSecretEnvelope } from "../src/security/secretEnvelope.js";
function requiredEnv(name: string): string {
const v = process.env[name]?.trim();
if (v === undefined || v === "") throw new Error(`missing required env: ${name}`);
return v;
}
async function main(): Promise<void> {
const databaseUrl = requiredEnv("DATABASE_URL");
const keyring = await loadLocalSecretKeyring();
const secrets = new LocalSecretEnvelope(keyring);
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
const organizationId = requiredEnv("HUB_SILO_ORGANIZATION_ID");
const feishuAppId = requiredEnv("FEISHU_APP_ID");
const feishuAppSecret = requiredEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requiredEnv("FEISHU_BOT_OPEN_ID");
const providerAuthToken = requiredEnv("ANTHROPIC_AUTH_TOKEN");
const providerBaseUrl = process.env["ANTHROPIC_BASE_URL"]?.trim() || "https://openrouter.ai/api";
const anthropicApiKey = process.env["ANTHROPIC_API_KEY"]?.trim() || "";
try {
const result = await bootstrapAlphaSilo(
prisma,
secrets,
{
organization: {
id: organizationId,
slug: "local-dev",
name: "Local Dev Silo",
},
owner: {
openId: feishuBotOpenId,
displayName: "Local Dev Owner",
},
feishu: {
appId: feishuAppId,
appSecret: feishuAppSecret,
botOpenId: feishuBotOpenId,
},
provider: {
providerId: "openrouter",
baseUrl: providerBaseUrl,
authToken: providerAuthToken,
...(anthropicApiKey !== "" ? { anthropicApiKey } : {}),
},
},
// Skip live network probes in local dev — Feishu/OpenRouter reachability
// is not required to seed the encrypted envelope rows.
{ feishu: async () => {}, provider: async () => {} },
);
console.log("bootstrap result:", JSON.stringify(result, null, 2));
} finally {
await prisma.$disconnect();
}
}
main().catch((error: unknown) => {
console.error("[dev-bootstrap-silo] failed:", error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+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;
});
+84
View File
@@ -0,0 +1,84 @@
/**
* Local dev seed for an existing Organization that predates the ADR-0024
* secret-envelope plane (e.g. the legacy `org_default` from the tenant-root
* migration). Creates ACTIVE Feishu Application + Provider (BYOK openrouter)
* connections with encrypted envelopes, using the local dev keyring.
*
* Idempotent: re-running rotates a new secret version if the connection
* already exists.
*
* Usage: npx tsx scripts/dev-seed-connections.ts
*/
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { FeishuApplicationConnectionService } from "../src/connections/feishuApplicationConnections.js";
import { ProviderConnectionService } from "../src/connections/providerConnections.js";
import { loadLocalSecretKeyring, LocalSecretEnvelope } from "../src/security/secretEnvelope.js";
function requiredEnv(name: string): string {
const v = process.env[name]?.trim();
if (v === undefined || v === "") throw new Error(`missing required env: ${name}`);
return v;
}
async function main(): Promise<void> {
const databaseUrl = requiredEnv("DATABASE_URL");
const organizationId = requiredEnv("HUB_SILO_ORGANIZATION_ID");
const keyring = await loadLocalSecretKeyring();
const secrets = new LocalSecretEnvelope(keyring);
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
const feishuAppId = requiredEnv("FEISHU_APP_ID");
const feishuAppSecret = requiredEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requiredEnv("FEISHU_BOT_OPEN_ID");
const providerAuthToken = requiredEnv("ANTHROPIC_AUTH_TOKEN");
const providerBaseUrl = process.env["ANTHROPIC_BASE_URL"]?.trim() || "https://openrouter.ai/api";
const anthropicApiKey = process.env["ANTHROPIC_API_KEY"]?.trim() || "";
// Pick an existing active OWNER/ADMIN as the actor for the audit rows.
const actor = await prisma.organizationMembership.findFirst({
where: { organizationId, role: { in: ["OWNER", "ADMIN"] }, revokedAt: null },
select: { userId: true },
});
if (actor === null) throw new Error(`no active OWNER/ADMIN membership on ${organizationId}; seed a member first`);
const actorUserId = actor.userId;
console.log("actor:", actorUserId);
try {
const feishuService = new FeishuApplicationConnectionService(
prisma,
secrets,
async () => {}, // skip live Feishu probe in local dev
);
const feishuResult = await feishuService.rotateCustomerApplication({
organizationId,
actorUserId,
appId: feishuAppId,
appSecret: feishuAppSecret,
botOpenId: feishuBotOpenId,
});
console.log("feishu connection:", JSON.stringify(feishuResult, null, 2));
const providerService = new ProviderConnectionService(
prisma,
secrets,
async () => {}, // skip live OpenRouter probe in local dev
);
const providerResult = await providerService.rotateByok({
organizationId,
actorUserId,
providerId: "openrouter",
baseUrl: providerBaseUrl,
authToken: providerAuthToken,
...(anthropicApiKey !== "" ? { anthropicApiKey } : {}),
});
console.log("provider connection:", JSON.stringify(providerResult, null, 2));
} finally {
await prisma.$disconnect();
}
}
main().catch((error: unknown) => {
console.error("[dev-seed-connections] failed:", error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+54
View File
@@ -10,8 +10,10 @@ import {
verifySession,
type SessionPayload,
} from "./session.js";
import { createPermissionAuthorizer, type AuthorizationAction } from "../../permissions/authorizer.js";
export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
const ANY_ORG_ROLE: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN", "MEMBER"];
export interface AuthContext {
readonly session: SessionPayload;
@@ -175,3 +177,55 @@ export async function sendError(
): Promise<void> {
await reply.status(statusCode).send({ error: { code, message } });
}
export interface ProjectAuthContext extends OrgAuthContext {
readonly projectId: string;
}
/**
* Resolve an org member (any role) viewing/mutating a project in their org, then
* enforce project-level permission via the PermissionGrant authorizer.
*
* `allowOrgAdminOversight=true` lets org OWNER/ADMIN through without a project
* grant — reserved for *read* oversight (listing/viewing). Mutations that the
* spec pins to project `manage` (e.g. `collaborator.manage`) must pass
* `allowOrgAdminOversight=false`: org role alone is not a project authorization
* root (spec `Permission.lean` / ADR-0004; the only out-of-role override is
* platform-admin force-release `RequiresAdmin`, not org admin).
*/
export async function requireProjectPermission(
request: FastifyRequest,
reply: FastifyReply,
deps: GuardDeps,
options: {
readonly orgSlug: string;
readonly projectId: string;
readonly action: AuthorizationAction;
readonly allowOrgAdminOversight: boolean;
},
): Promise<ProjectAuthContext | null> {
const auth = await requireOrgRole(request, reply, deps, {
orgSlug: options.orgSlug,
roles: ANY_ORG_ROLE,
});
if (auth === null) return null;
await requireOrgProject(deps, auth.organization.id, options.projectId);
if (options.allowOrgAdminOversight && ORG_ADMIN_ROLES.includes(auth.membershipRole)) {
return { ...auth, projectId: options.projectId };
}
const decision = await createPermissionAuthorizer(deps.prisma).can({
actor: { feishuOpenId: auth.feishuOpenId },
action: options.action,
resource: { type: "PROJECT", id: options.projectId },
});
if (!decision.allowed) {
await sendError(
reply,
403,
"forbidden",
`project ${options.projectId} requires ${decision.requiredRole} (${options.action}): ${decision.reason}`,
);
return null;
}
return { ...auth, projectId: options.projectId };
}
+4 -1
View File
@@ -35,9 +35,11 @@ export async function handleRouteError(reply: FastifyReply, err: unknown): Promi
await sendError(reply, mapped.statusCode, mapped.code, mapped.message);
return;
}
reply.log.error({ err }, "unmapped route error");
await sendError(reply, 500, "internal_error", "internal error");
return;
}
reply.log.error({ err }, "unmapped route error");
await sendError(reply, 500, "internal_error", "internal error");
}
@@ -71,7 +73,8 @@ function mapDomainError(message: string): { statusCode: number; code: string; me
lower.includes("is required") ||
lower.includes("already") ||
lower.includes("invalid") ||
lower.includes("accepts only")
lower.includes("accepts only") ||
lower.includes("must be")
) {
return { statusCode: 400, code: "bad_request", message };
}
+5 -1
View File
@@ -1,11 +1,12 @@
/**
* Registers org-admin HTTP surface: auth, org APIs, (later) static SPA.
* Registers org-admin HTTP surface: auth, org APIs, and the static SPA shell.
*/
import cookie from "@fastify/cookie";
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { registerAuthRoutes } from "./routes/authRoutes.js";
import { registerOrgRoutes } from "./routes/orgRoutes.js";
import { registerStaticSpa } from "./static.js";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../connections/providerReadiness.js";
import type { FeishuReadinessProbe } from "../connections/feishuReadiness.js";
@@ -62,4 +63,7 @@ export async function registerAdminPlugin(
? { feishuConnectionReadinessProbe: config.feishuConnectionReadinessProbe }
: {}),
});
// After API + /admin/login so SPA fallback does not shadow auth routes.
await registerStaticSpa(app);
}
+19 -7
View File
@@ -5,7 +5,7 @@ import {
listProjectTeamAccess,
revokeTeamProjectAccess,
} from "../../permissions/projectTeamAccess.js";
import { requireOrgProject, requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { requireProjectPermission, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
const ROLES: readonly PermissionRole[] = ["READ", "EDIT", "MANAGE"];
@@ -19,9 +19,13 @@ export async function registerAccessRoutes(
app.get("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "project.read",
allowOrgAdminOversight: true,
});
if (auth === null) return;
await requireOrgProject(guardDeps, auth.organization.id, projectId);
return { access: await listProjectTeamAccess(config.prisma, projectId) };
} catch (err) {
return handleRouteError(reply, err);
@@ -31,9 +35,13 @@ export async function registerAccessRoutes(
app.put("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "collaborator.manage",
allowOrgAdminOversight: false,
});
if (auth === null) return;
await requireOrgProject(guardDeps, auth.organization.id, projectId);
const body = request.body as {
teamId?: unknown;
teamSlug?: unknown;
@@ -67,9 +75,13 @@ export async function registerAccessRoutes(
projectId: string;
teamId: string;
};
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "collaborator.manage",
allowOrgAdminOversight: false,
});
if (auth === null) return;
await requireOrgProject(guardDeps, auth.organization.id, projectId);
const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId });
return { revoked: count };
} catch (err) {
+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);
}
});
}
+82 -11
View File
@@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { resolveActiveFeishuApplication } from "../../connections/feishuApplicationConnections.js";
import { upsertScopedFeishuIdentity } from "../../feishu/identityNamespace.js";
import { upsertScopedFeishuIdentityInTransaction } from "../../feishu/identityNamespace.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import {
buildAuthorizeUrl,
@@ -164,16 +164,54 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
let userId: string;
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
const identity = await upsertScopedFeishuIdentity(config.prisma, {
connectionId: statePayload.connectionId,
openId: feishuUser.openId,
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
displayName: feishuUser.displayName,
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
const connectionId = statePayload.connectionId;
const organizationId = statePayload.organizationId;
const identity = await config.prisma.$transaction(async (tx) => {
const resolved = await upsertScopedFeishuIdentityInTransaction(tx, {
connectionId,
expectedOrganizationId: organizationId,
openId: feishuUser.openId,
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
displayName: feishuUser.displayName,
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
});
const activeMembership = await tx.organizationMembership.findFirst({
where: {
organizationId,
userId: resolved.userId,
revokedAt: null,
},
select: { id: true },
});
if (activeMembership === null) {
const revokedMembership = await tx.organizationMembership.findFirst({
where: {
organizationId,
userId: resolved.userId,
revokedAt: { not: null },
},
select: { id: true },
});
if (revokedMembership === null) {
await tx.organizationMembership.create({
data: {
organizationId,
userId: resolved.userId,
role: "MEMBER",
},
});
await tx.auditEntry.create({
data: {
organizationId,
actorUserId: resolved.userId,
action: "organization_member.oauth_auto_joined",
metadata: { connectionId: resolved.connectionId, role: "MEMBER" },
},
});
}
}
return resolved;
});
if (identity.organizationId !== statePayload.organizationId) {
throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch");
}
userId = identity.userId;
setSessionCookie(reply, config, {
userId,
@@ -235,6 +273,34 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
return reply.status(204).send();
});
app.get("/auth/feishu/complete", async (request, reply) => {
const query = request.query as { org?: string };
const organizationName = typeof query.org === "string" && query.org.trim() !== ""
? query.org.trim()
: "当前组织";
return reply.type("text/html").send(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Educraft 登录成功</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#f6f7f9;color:#1a1a1a}
.card{background:#fff;padding:2rem 2.5rem;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);max-width:25rem;text-align:center}
.ok{font-size:3rem;margin:0 0 .5rem}.hint{color:#646a73;line-height:1.6}
</style>
</head>
<body>
<main class="card">
<p class="ok">✅</p>
<h1>登录并加入组织成功</h1>
<p>你已加入 ${escapeHtml(organizationName)}。</p>
<p class="hint">现在可以关闭本页面,返回飞书群再次 @机器人继续使用。</p>
</main>
</body>
</html>`);
});
app.get("/api/me", async (request, reply) => {
try {
const auth = await requireSession(request, reply, guardDeps);
@@ -363,10 +429,15 @@ async function resolvePostLoginRedirect(
revokedAt: null,
organization: { status: "ACTIVE" },
},
select: { organization: { select: { slug: true } } },
select: { organization: { select: { slug: true, name: true } } },
});
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 orgRoot;
}
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
}
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
+64
View File
@@ -0,0 +1,64 @@
/**
* Org-admin capacity policy routes (ADR-0022).
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { getCapacityPolicy, setCapacityPolicy } from "../../org/capacityPolicy.js";
import { isCapacityDimension } from "../../capacity/dimensions.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export async function registerCapacityRoutes(
app: FastifyInstance,
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug/capacity-policy", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await getCapacityPolicy(config.prisma, auth.organization.id);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/capacity-policy", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { limits?: unknown };
if (body?.limits === null || typeof body.limits !== "object") {
return reply.status(400).send({
error: { code: "bad_request", message: "limits object is required" },
});
}
const limits: Record<string, number | null> = {};
for (const [key, value] of Object.entries(body.limits as Record<string, unknown>)) {
if (!isCapacityDimension(key)) {
return reply.status(400).send({
error: { code: "bad_request", message: `unknown capacity dimension: ${key}` },
});
}
if (value === null) {
limits[key] = null;
} else if (typeof value === "number" && Number.isFinite(value)) {
limits[key] = value;
} else {
return reply.status(400).send({
error: { code: "bad_request", message: `limit for ${key} must be a number or null` },
});
}
}
return await setCapacityPolicy(config.prisma, {
organizationId: auth.organization.id,
limits,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}
+39 -4
View File
@@ -10,13 +10,15 @@ import {
createOrgFolder,
createOrgProject,
getOrgProjectDetail,
listMyProjects,
listOrgExplorer,
moveOrgProjectToFolder,
renameFolder,
renameProject,
} from "../../org/explorer.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { requireOrgRole, requireProjectPermission, ORG_ADMIN_ROLES, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
import { createPermissionAuthorizer } from "../../permissions/authorizer.js";
export interface ExplorerRouteConfig {
readonly prisma: PrismaClient;
@@ -41,6 +43,24 @@ export async function registerExplorerRoutes(
}
});
app.get("/api/org/:orgSlug/my-projects", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, {
orgSlug,
roles: ["OWNER", "ADMIN", "MEMBER"],
});
if (auth === null) return;
const projects = await listMyProjects(config.prisma, {
organizationId: auth.organization.id,
actorFeishuOpenId: auth.feishuOpenId,
});
return { projects };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
@@ -135,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);
}
@@ -144,12 +164,27 @@ export async function registerExplorerRoutes(
app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "project.read",
allowOrgAdminOversight: true,
});
if (auth === null) return;
return await getOrgProjectDetail(config.prisma, {
const detail = await getOrgProjectDetail(config.prisma, {
organizationId: auth.organization.id,
projectId,
});
const manageDecision = await createPermissionAuthorizer(config.prisma).can({
actor: { feishuOpenId: auth.feishuOpenId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
return {
...detail,
actorIsOrgAdmin: ORG_ADMIN_ROLES.includes(auth.membershipRole),
actorCanManageProject: manageDecision.allowed,
};
} catch (err) {
return handleRouteError(reply, err);
}
+10
View File
@@ -15,6 +15,8 @@ import { registerMembersRoutes } from "./membersRoutes.js";
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";
@@ -105,6 +107,14 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerCapacityRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerAgentConfigRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerSessionsAndUsageRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
+8 -2
View File
@@ -18,10 +18,16 @@ export async function registerTeamsRoutes(
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
// Read-only listing is open to any active org member so project MANAGE
// holders can pick a team when granting TEAM→PROJECT access (ADR-0004).
// Mutations below stay org-admin only.
app.get("/api/org/:orgSlug/teams", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
const auth = await requireOrgRole(request, reply, guardDeps, {
orgSlug,
roles: ["OWNER", "ADMIN", "MEMBER"],
});
if (auth === null) return;
return { teams: await listOrgTeams(config.prisma, auth.organization.id) };
} catch (err) {
@@ -34,7 +40,7 @@ export async function registerTeamsRoutes(
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { slug?: unknown; name?: unknown; description?: unknown };
const body = (request.body ?? {}) as { slug?: unknown; name?: unknown; description?: unknown };
if (typeof body.slug !== "string" || typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "slug and name are required" },
+82
View File
@@ -0,0 +1,82 @@
/**
* Serves the org-admin SPA (built by SvelteKit via `admin-web/build/`) and
* the SPA index fallback for client-side routes under `/admin/*`.
*
* The SvelteKit project lives in `hub/admin-web/`. Run `npm run build` there
* to produce the static output in `admin-web/build/`. In development, use
* `npm run dev` in `admin-web/` which proxies `/api` and `/auth` to the Hub.
*
* Override the UI directory with `CPH_ADMIN_UI_DIR` env if needed.
*/
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, extname, join, resolve as resolvePath } from "node:path";
import type { FastifyInstance } from "fastify";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".json": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
};
function resolveUiDir(): string {
const override = process.env["CPH_ADMIN_UI_DIR"];
if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url));
return resolvePath(join(here, "..", "..", "admin-web", "build"));
}
export async function registerStaticSpa(app: FastifyInstance): Promise<void> {
const uiDir = resolveUiDir();
if (!existsSync(join(uiDir, "index.html"))) {
app.log.warn(
{ uiDir },
"admin-web/build not found; SPA shell disabled. Run `npm run build` in admin-web/ to enable. Org admin APIs remain fully functional.",
);
return;
}
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
// SvelteKit static assets (_app/*, favicon, etc.)
app.get("/_app/*", async (request, reply) => {
const rel = (request.params as { "*": string })["*"];
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
try {
const buf = await readFile(join(uiDir, "_app", safe));
const mime = MIME[extname(safe)] ?? "application/octet-stream";
return reply.type(mime).send(buf);
} catch {
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
}
});
// Other top-level static assets (favicon.svg, robots.txt, etc.)
app.get("/favicon.svg", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "favicon.svg"));
return reply.type("image/svg+xml").send(buf);
} catch {
return reply.status(404).send();
}
});
// SPA client-side route fallback. Auth registers `/admin/login` first so it
// takes precedence; everything else under /admin/* serves the index so
// SvelteKit's client-side router can resolve the view.
app.get("/admin", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
app.get("/admin/*", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
}

Some files were not shown because too many files have changed in this diff Show More