feat(resources): slice 3 — persist mcp JWT, proxy, list bindings
Backend:
1. Extend SavedProvider schema with optional mcpAuth field
(accessToken / refreshToken / accessExpiresAt / refreshExpiresAt /
managerLoginUrl / userId / channelId). Wired through
CreateProviderInput and UpdateProviderInput so providerService
persists tokens to providers.json.
2. handleLoginWithCredentials (Path A) now decodes the JWT exp claim
of both tokens (no signature verification — issuer just authed us)
and stores the resulting mcpAuth object on the saved provider.
Documented TTL (24h access / 7d refresh) used as fallback if exp
claim missing.
3. New handler api/heicode-resources.ts — proxy for the local server
route /api/heicode-resources/{*path}. It:
- Reads mcpAuth from the active provider (401 if missing)
- Refreshes the access token if < 60s from expiry by calling
<managerLoginUrl>/api/auth/refresh; persists the new pair
back to providers.json before forwarding
- Returns 401 if refresh token is also expired (re-login needed)
- Forwards request to <managerLoginUrl>/api/resources or
/api/resource-grants with Authorization: Bearer <accessToken>
- Passes status + body through
4. router.ts: register case 'heicode-resources'.
5. errorHandler: add ApiError.unauthorized(401) and badGateway(502)
factories used by the proxy.
Desktop:
6. New api/heicodeResources.ts client + types (ResourceBinding,
ResourceGrant, etc. mirroring mcp-server contract). Slice 3 only
exposes listBindings + getBinding.
7. New stores/resourceStore.ts (zustand) with bindings, isLoading,
hasFetched, error + fetchBindings action.
8. pages/ResourceBindings.tsx upgraded from shell to a real list:
- Auto-fetches on mount
- Shows loading skeleton, error banner with dismiss, empty state,
or a 5-column table (Name / Type / External ref / Status /
Permission scope)
- Refresh button in the header
- Footer note about CRUD coming in slice 4
9. i18n: 14 new keys (common.dismiss + resources.refresh / refreshing
/ col.* / error.title / footer.cruComingSoon) in both zh + en.
E2E behaviour after install: log in via 55@55.com / By@123456., open
Resources tab — local server proxies to apimtaiji and lists whatever
ResourceBindings the user has on mcp-server. New test account 55@55.com
has 0 bindings, so empty state shows up.
Slice 4 next: Create / Edit / Delete binding modals + Grants UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
// desktop/src/api/heicodeResources.ts
|
||||
//
|
||||
// API client for the local server's /api/heicode-resources/* proxy.
|
||||
// The local server forwards each call to mcp-server (apimtaiji.azure-api.net)
|
||||
// using the JWT stored in the active provider's mcpAuth, with transparent
|
||||
// refresh on the 24h boundary.
|
||||
//
|
||||
// Server contract: cc-haha/src/server/api/heicode-resources.ts
|
||||
// mcp-server contract: Heicode-接口契约文档.md §2 + §3
|
||||
|
||||
import { api } from './client'
|
||||
|
||||
export type ResourceType =
|
||||
| 'git'
|
||||
| 'sk'
|
||||
| 'project_doc'
|
||||
| 'cloud_account'
|
||||
| 'cloud_resource'
|
||||
|
||||
export type ResourceStatus = 'pending' | 'active' | 'disabled' | 'revoked'
|
||||
|
||||
export type ResourceBinding = {
|
||||
id: string
|
||||
user_id: string
|
||||
type: ResourceType
|
||||
name: string
|
||||
external_ref?: string
|
||||
metadata: Record<string, unknown>
|
||||
permission_scope: string[]
|
||||
constraints: Record<string, unknown>
|
||||
secret_ref?: string
|
||||
status: ResourceStatus
|
||||
created_by: string
|
||||
updated_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ResourceBindingsList = {
|
||||
items: ResourceBinding[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export type GrantStatus = 'active' | 'suspended' | 'revoked' | 'expired'
|
||||
|
||||
export type ResourceGrant = {
|
||||
id: string
|
||||
user_id: string
|
||||
resource_id: string
|
||||
binding_scope: string
|
||||
role?: string
|
||||
agent_id?: string | null
|
||||
allowed_actions: string[]
|
||||
constraints: Record<string, unknown>
|
||||
status: GrantStatus
|
||||
expires_at?: string | null
|
||||
created_by: string
|
||||
revoked_by?: string | null
|
||||
created_at: string
|
||||
revoked_at?: string | null
|
||||
}
|
||||
|
||||
export type ResourceGrantsList = {
|
||||
items: ResourceGrant[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export type ApiEnvelope<T> = {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
const BASE = '/api/heicode-resources'
|
||||
|
||||
export const heicodeResourcesApi = {
|
||||
listBindings(opts?: { type?: ResourceType; status?: ResourceStatus }) {
|
||||
const params = new URLSearchParams()
|
||||
if (opts?.type) params.set('type', opts.type)
|
||||
if (opts?.status) params.set('status', opts.status)
|
||||
const qs = params.toString()
|
||||
return api.get<ApiEnvelope<ResourceBindingsList>>(
|
||||
`${BASE}${qs ? `?${qs}` : ''}`,
|
||||
)
|
||||
},
|
||||
|
||||
getBinding(id: string) {
|
||||
return api.get<ApiEnvelope<ResourceBinding>>(`${BASE}/${encodeURIComponent(id)}`)
|
||||
},
|
||||
|
||||
// Slice 4 will add createBinding / updateBinding / deleteBinding /
|
||||
// listGrants / createGrant / revokeGrant.
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const en = {
|
||||
'common.rename': 'Rename',
|
||||
'common.retry': 'Retry',
|
||||
'common.loading': 'Loading...',
|
||||
'common.dismiss': 'Dismiss',
|
||||
'common.select': 'Select',
|
||||
'common.enable': 'Enable',
|
||||
'common.disable': 'Disable',
|
||||
@@ -959,9 +960,18 @@ export const en = {
|
||||
// ─── Resource Bindings (mcp-server P1 nine endpoints) ────
|
||||
'resources.title': 'Resources',
|
||||
'resources.subtitle': 'Bind Git repos / SK / project docs / cloud accounts / cloud resources, then grant them to sub-agents.',
|
||||
'resources.empty.title': 'Not wired up yet',
|
||||
'resources.empty.body': 'This page integrates the Heicode Manager P1 resource model APIs (mcp-server has 9 endpoints live). Create / list / revoke bindings and grants are coming soon.',
|
||||
'resources.empty.comingSoon': 'Coming soon — Manager service-credential auth and grant UI under development',
|
||||
'resources.refresh': 'Refresh',
|
||||
'resources.refreshing': 'Refreshing…',
|
||||
'resources.empty.title': 'No resource bindings yet',
|
||||
'resources.empty.body': 'Create Git repo / SK / project doc / cloud account / cloud resource bindings under your Heicode Manager account to manage and grant them here.',
|
||||
'resources.empty.comingSoon': 'Create / delete UI ships in the next slice; until then create them via Heicode Web console.',
|
||||
'resources.error.title': 'Failed to load',
|
||||
'resources.col.name': 'Name',
|
||||
'resources.col.type': 'Type',
|
||||
'resources.col.externalRef': 'External ref',
|
||||
'resources.col.status': 'Status',
|
||||
'resources.col.scope': 'Permission scope',
|
||||
'resources.footer.cruComingSoon': 'This version only lists existing bindings. Create / edit / delete / grants UI ships in the next slice.',
|
||||
|
||||
// ─── HeiCode Login ──────────────────────────────────────
|
||||
'login.subtitle': 'Sign in to start using Heicode',
|
||||
|
||||
@@ -12,6 +12,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'common.rename': '重命名',
|
||||
'common.retry': '重试',
|
||||
'common.loading': '加载中...',
|
||||
'common.dismiss': '关闭',
|
||||
'common.select': '选择',
|
||||
'common.enable': '启用',
|
||||
'common.disable': '禁用',
|
||||
@@ -961,9 +962,18 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// ─── 资源绑定(接 mcp-server P1 9 接口)──────────────
|
||||
'resources.title': '资源绑定',
|
||||
'resources.subtitle': '绑定 Git 仓库 / SK / 项目文档 / 云账号 / 云资源,并把它们授给子 Agent 使用。',
|
||||
'resources.empty.title': '尚未实装',
|
||||
'resources.empty.body': '本页对接 Heicode Manager 的 P1 资源模型接口(mcp-server 已上线 9 个端点)。后续将支持创建 / 列表 / 撤销绑定与授权。',
|
||||
'resources.empty.comingSoon': '即将上线 — Manager 服务凭据接入与授权 UI 开发中',
|
||||
'resources.refresh': '刷新',
|
||||
'resources.refreshing': '刷新中…',
|
||||
'resources.empty.title': '还没有任何资源绑定',
|
||||
'resources.empty.body': '在 Heicode Manager 平台账号下创建 Git 仓库、SK、项目文档、云账号或云资源的绑定,便可在此处统一管理与授权。',
|
||||
'resources.empty.comingSoon': '客户端创建/删除入口在下一版本上线;当前可在 Heicode Web 控制台创建。',
|
||||
'resources.error.title': '加载失败',
|
||||
'resources.col.name': '名称',
|
||||
'resources.col.type': '类型',
|
||||
'resources.col.externalRef': '外部引用',
|
||||
'resources.col.status': '状态',
|
||||
'resources.col.scope': '权限范围',
|
||||
'resources.footer.cruComingSoon': '当前版本仅展示绑定列表。新建 / 编辑 / 删除 / 授权管理将在下一版本提供。',
|
||||
|
||||
// ─── HeiCode 登录 ──────────────────────────────────────
|
||||
'login.subtitle': '登录以开始使用 Heicode',
|
||||
|
||||
@@ -1,52 +1,178 @@
|
||||
// desktop/src/pages/ResourceBindings.tsx
|
||||
//
|
||||
// 资源绑定页面 — 接 mcp-server 的 P1 9 接口(已上线):
|
||||
// GET /api/resources 列出当前用户所有 ResourceBinding
|
||||
// POST /api/resources 新建 binding(git/sk/project_doc/cloud_account/cloud_resource)
|
||||
// GET /api/resources/{id} 单个 binding 详情
|
||||
// PUT /api/resources/{id} 更新 binding
|
||||
// DELETE /api/resources/{id} 软删除(status=revoked)
|
||||
// POST /api/resource-grants 把 binding 授给 role/agent
|
||||
// GET /api/resource-grants 列出当前用户所有 grant
|
||||
// GET /api/resource-grants/{id} 单个 grant
|
||||
// DELETE /api/resource-grants/{id} 撤销
|
||||
//
|
||||
// 当前为切片 2 的 shell —— 显示标题 + 占位说明,让用户能看到入口。
|
||||
// 切片 3+4 接入实际 CRUD。
|
||||
// 资源绑定页面。Slice 3:列出当前用户的所有 ResourceBinding(接 mcp-server
|
||||
// /api/resources,通过本地 server /api/heicode-resources 代理)。
|
||||
// Slice 4 再加 Create/Delete + Grants。
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useResourceStore } from '../stores/resourceStore'
|
||||
import type { ResourceBinding, ResourceStatus } from '../api/heicodeResources'
|
||||
|
||||
const TYPE_LABELS: Record<ResourceBinding['type'], string> = {
|
||||
git: 'Git',
|
||||
sk: 'SK',
|
||||
project_doc: '项目文档',
|
||||
cloud_account: '云账号',
|
||||
cloud_resource: '云资源',
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<ResourceStatus, string> = {
|
||||
active: 'text-[var(--color-success)] border-[var(--color-success)]/30 bg-[var(--color-success)]/10',
|
||||
pending: 'text-[var(--color-warning)] border-[var(--color-warning)]/30 bg-[var(--color-warning)]/10',
|
||||
disabled: 'text-[var(--color-text-tertiary)] border-[var(--color-border)] bg-[var(--color-surface-container-low)]',
|
||||
revoked: 'text-[var(--color-error)] border-[var(--color-error)]/30 bg-[var(--color-error)]/10',
|
||||
}
|
||||
|
||||
export function ResourceBindings() {
|
||||
const t = useTranslation()
|
||||
const { bindings, isLoading, hasFetched, error, fetchBindings, clearError } =
|
||||
useResourceStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFetched) {
|
||||
void fetchBindings()
|
||||
}
|
||||
}, [hasFetched, fetchBindings])
|
||||
|
||||
const showEmpty = hasFetched && !isLoading && !error && bindings.length === 0
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col overflow-auto bg-[var(--color-surface)]">
|
||||
<div className="mx-auto w-full max-w-4xl px-8 py-12">
|
||||
<header className="mb-8">
|
||||
<h1
|
||||
className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]"
|
||||
style={{ fontFamily: 'var(--font-headline)' }}
|
||||
<div className="mx-auto w-full max-w-5xl px-8 py-10">
|
||||
<header className="mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1
|
||||
className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]"
|
||||
style={{ fontFamily: 'var(--font-headline)' }}
|
||||
>
|
||||
{t('resources.title')}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-[var(--color-text-secondary)]">
|
||||
{t('resources.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchBindings()}
|
||||
disabled={isLoading}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{t('resources.title')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
|
||||
{t('resources.subtitle')}
|
||||
</p>
|
||||
{isLoading ? t('resources.refreshing') : t('resources.refresh')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-8 text-center shadow-[var(--shadow-dropdown)]">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[22px]">link</span>
|
||||
{error ? (
|
||||
<div className="mb-6 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-4 py-3 text-sm text-[var(--color-error)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{t('resources.error.title')}</div>
|
||||
<div className="mt-1 text-xs opacity-80">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className="text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t('common.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
|
||||
{t('resources.empty.title')}
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{t('resources.empty.body')}
|
||||
</p>
|
||||
<p className="mx-auto mt-4 max-w-md text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('resources.empty.comingSoon')}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!hasFetched && isLoading ? (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-8 text-center text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showEmpty ? (
|
||||
<section className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-8 text-center shadow-[var(--shadow-dropdown)]">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[22px]">link_off</span>
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
|
||||
{t('resources.empty.title')}
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{t('resources.empty.body')}
|
||||
</p>
|
||||
<p className="mx-auto mt-4 max-w-md text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('resources.empty.comingSoon')}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{bindings.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[11px] uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('resources.col.name')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('resources.col.type')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('resources.col.externalRef')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('resources.col.status')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('resources.col.scope')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bindings.map((b) => (
|
||||
<tr
|
||||
key={b.id}
|
||||
className="border-b border-[var(--color-border-separator)] last:border-0 transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="font-medium text-[var(--color-text-primary)]">{b.name}</div>
|
||||
<div className="mt-0.5 font-mono text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{b.id.slice(0, 8)}…
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<span className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2 py-0.5 text-xs text-[var(--color-text-secondary)]">
|
||||
{TYPE_LABELS[b.type] ?? b.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-xs text-[var(--color-text-secondary)] break-all">
|
||||
{b.external_ref ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${STATUS_TONE[b.status]}`}
|
||||
>
|
||||
{b.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-xs text-[var(--color-text-secondary)]">
|
||||
{b.permission_scope.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{b.permission_scope.slice(0, 3).map((s) => (
|
||||
<code
|
||||
key={s}
|
||||
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]"
|
||||
>
|
||||
{s}
|
||||
</code>
|
||||
))}
|
||||
{b.permission_scope.length > 3 ? (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
+{b.permission_scope.length - 3}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('resources.footer.cruComingSoon')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// desktop/src/stores/resourceStore.ts
|
||||
//
|
||||
// Zustand store for ResourceBinding / ResourceGrant lists. Talks to the local
|
||||
// /api/heicode-resources/* proxy (which forwards to mcp-server with refresh).
|
||||
//
|
||||
// Slice 3 scope: list bindings only. Slice 4 adds create/delete/grants.
|
||||
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
heicodeResourcesApi,
|
||||
type ResourceBinding,
|
||||
} from '../api/heicodeResources'
|
||||
|
||||
type ResourceState = {
|
||||
bindings: ResourceBinding[]
|
||||
isLoading: boolean
|
||||
hasFetched: boolean
|
||||
error: string | null
|
||||
|
||||
fetchBindings: () => Promise<void>
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export const useResourceStore = create<ResourceState>((set) => ({
|
||||
bindings: [],
|
||||
isLoading: false,
|
||||
hasFetched: false,
|
||||
error: null,
|
||||
|
||||
fetchBindings: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const res = await heicodeResourcesApi.listBindings()
|
||||
const items = res.success ? (res.data?.items ?? []) : []
|
||||
set({
|
||||
bindings: items,
|
||||
isLoading: false,
|
||||
hasFetched: true,
|
||||
error: res.success ? null : (res.message || '加载资源绑定失败'),
|
||||
})
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
hasFetched: true,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}))
|
||||
@@ -268,6 +268,28 @@ async function handleLoginWithCredentials(req: Request): Promise<Response> {
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw ApiError.badRequest('平台登录响应缺少 token 字段')
|
||||
}
|
||||
const mcpUserId = mgrJson.data?.user?.id
|
||||
const mcpChannelId = mgrJson.data?.user?.channelId
|
||||
|
||||
// Decode JWT exp claims (no signature verification — we already trust the
|
||||
// platform we just authenticated against). Used downstream to know when to
|
||||
// refresh the access token before each /api/resources call.
|
||||
const accessExp = decodeJwtExpMs(accessToken)
|
||||
const refreshExp = decodeJwtExpMs(refreshToken)
|
||||
// Defaults if exp missing: assume documented TTLs (24h access / 7d refresh).
|
||||
const now = Date.now()
|
||||
const accessExpiresAt = accessExp ?? now + 24 * 3600 * 1000
|
||||
const refreshExpiresAt = refreshExp ?? now + 7 * 24 * 3600 * 1000
|
||||
|
||||
const mcpAuth = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessExpiresAt,
|
||||
refreshExpiresAt,
|
||||
managerLoginUrl: managerBase,
|
||||
...(mcpUserId !== undefined && { userId: mcpUserId }),
|
||||
...(mcpChannelId !== undefined && { channelId: mcpChannelId }),
|
||||
}
|
||||
|
||||
// ── Step 2: POST <baseUrl>/api/user/session/from-agnet ─────────
|
||||
const faRes = await fetch(`${base}/api/user/session/from-agnet`, {
|
||||
@@ -348,7 +370,29 @@ async function handleLoginWithCredentials(req: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
// ── Step 4: Save + activate via the existing pipeline ─────────
|
||||
return await loginAndActivate(providerId, token, displayName)
|
||||
return await loginAndActivate(providerId, token, displayName, mcpAuth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the `exp` claim of a JWT (no signature verification — we already
|
||||
* trust the issuer because we just successfully authenticated against it).
|
||||
* Returns Unix epoch milliseconds, or null if the token is malformed.
|
||||
*/
|
||||
function decodeJwtExpMs(jwt: string): number | null {
|
||||
try {
|
||||
const parts = jwt.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const payloadB64 = parts[1]!
|
||||
// base64url → base64
|
||||
const base64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const json = Buffer.from(padded, 'base64').toString('utf-8')
|
||||
const claims = JSON.parse(json) as { exp?: number }
|
||||
if (typeof claims.exp !== 'number') return null
|
||||
return claims.exp * 1000 // JWT exp is seconds; we want ms
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OAuth scaffold (TODO: 平台支持后实装) ──────────────────────
|
||||
@@ -447,6 +491,15 @@ async function loginAndActivate(
|
||||
providerId: SupportedLoginProviderId,
|
||||
apiKey: string,
|
||||
displayName?: string,
|
||||
mcpAuth?: {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
accessExpiresAt: number
|
||||
refreshExpiresAt: number
|
||||
managerLoginUrl: string
|
||||
userId?: string
|
||||
channelId?: string
|
||||
},
|
||||
): Promise<Response> {
|
||||
if (!isSupportedLoginProvider(providerId)) {
|
||||
throw ApiError.badRequest(`Unsupported provider: ${providerId}`)
|
||||
@@ -496,6 +549,7 @@ async function loginAndActivate(
|
||||
baseUrl: preset.baseUrl,
|
||||
apiFormat: preset.apiFormat,
|
||||
models,
|
||||
...(mcpAuth && { mcpAuth }),
|
||||
})
|
||||
} else {
|
||||
saved = await providerService.addProvider({
|
||||
@@ -505,6 +559,7 @@ async function loginAndActivate(
|
||||
baseUrl: preset.baseUrl,
|
||||
apiFormat: preset.apiFormat,
|
||||
models,
|
||||
...(mcpAuth && { mcpAuth }),
|
||||
})
|
||||
}
|
||||
await providerService.activateProvider(saved.id)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Heicode Resources Proxy — exposes mcp-server's P1 ResourceBinding /
|
||||
* ResourceGrant endpoints to the desktop client through the local server.
|
||||
*
|
||||
* Why proxy?
|
||||
* - Desktop UI shouldn't deal with cross-origin / token refresh / Manager
|
||||
* base URL switching. The local Bun server already holds the tokens
|
||||
* (in providers.json under `mcpAuth`) so it's the natural place to
|
||||
* attach the Authorization header and refresh transparently.
|
||||
*
|
||||
* Routes (mirror mcp-server contract `Heicode-接口契约文档.md` §2 + §3):
|
||||
*
|
||||
* GET /api/heicode-resources → /api/resources
|
||||
* POST /api/heicode-resources → /api/resources
|
||||
* GET /api/heicode-resources/:id → /api/resources/:id
|
||||
* PUT /api/heicode-resources/:id → /api/resources/:id
|
||||
* DELETE /api/heicode-resources/:id → /api/resources/:id
|
||||
*
|
||||
* GET /api/heicode-resources/grants → /api/resource-grants
|
||||
* POST /api/heicode-resources/grants → /api/resource-grants
|
||||
* GET /api/heicode-resources/grants/:id → /api/resource-grants/:id
|
||||
* DELETE /api/heicode-resources/grants/:id → /api/resource-grants/:id
|
||||
*
|
||||
* Auth: pulls JWT pair from active provider's `mcpAuth`. If the access
|
||||
* token is within 60s of expiry (or already expired) but refresh token is
|
||||
* still valid, transparently calls mcp-server's /api/auth/refresh and
|
||||
* persists the new token pair before forwarding the request.
|
||||
*/
|
||||
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import type { McpAuth, SavedProvider } from '../types/provider.js'
|
||||
|
||||
const providerService = new ProviderService()
|
||||
|
||||
const ACCESS_REFRESH_BUFFER_MS = 60_000 // refresh if < 60s left on access token
|
||||
|
||||
export async function handleHeicodeResourcesApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// segments: ['api', 'heicode-resources', ...rest]
|
||||
const rest = segments.slice(2)
|
||||
const isGrants = rest[0] === 'grants'
|
||||
const upstreamBase = isGrants ? '/api/resource-grants' : '/api/resources'
|
||||
const tail = isGrants ? rest.slice(1) : rest
|
||||
const upstreamPath = tail.length > 0 ? `${upstreamBase}/${tail.join('/')}` : upstreamBase
|
||||
|
||||
const { provider, mcpAuth } = await getActiveMcpAuth()
|
||||
const freshAuth = await ensureFreshAccessToken(provider, mcpAuth)
|
||||
|
||||
const upstreamUrl =
|
||||
`${freshAuth.managerLoginUrl.replace(/\/+$/, '')}${upstreamPath}` +
|
||||
(url.search || '')
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${freshAuth.accessToken}`,
|
||||
'X-Request-Id': crypto.randomUUID(),
|
||||
}
|
||||
if (req.method === 'POST' || req.method === 'PUT') {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
headers['Accept'] = 'application/json'
|
||||
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
}
|
||||
if (req.method === 'POST' || req.method === 'PUT') {
|
||||
init.body = await req.text()
|
||||
}
|
||||
|
||||
const upstream = await fetch(upstreamUrl, init).catch((err) => {
|
||||
throw ApiError.badGateway(
|
||||
`调用 Heicode Manager 失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
})
|
||||
|
||||
// Pass through status + body. We don't stream — payloads are small.
|
||||
const body = await upstream.text()
|
||||
return new Response(body, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
'Content-Type': upstream.headers.get('content-type') || 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return errorResponse(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function getActiveMcpAuth(): Promise<{ provider: SavedProvider; mcpAuth: McpAuth }> {
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
if (!activeId) {
|
||||
throw ApiError.unauthorized('未登录 Heicode Manager。请先登录。')
|
||||
}
|
||||
const provider = providers.find(p => p.id === activeId)
|
||||
if (!provider) {
|
||||
throw ApiError.unauthorized('未找到当前活跃 provider。请重新登录。')
|
||||
}
|
||||
if (!provider.mcpAuth) {
|
||||
throw ApiError.unauthorized(
|
||||
'当前登录方式不支持资源绑定接口。请退出后用邮箱+密码登录(mcp-server 平台账号)。',
|
||||
)
|
||||
}
|
||||
return { provider, mcpAuth: provider.mcpAuth }
|
||||
}
|
||||
|
||||
async function ensureFreshAccessToken(
|
||||
provider: SavedProvider,
|
||||
mcpAuth: McpAuth,
|
||||
): Promise<McpAuth> {
|
||||
const now = Date.now()
|
||||
|
||||
// Access token still has plenty of life → use as-is.
|
||||
if (mcpAuth.accessExpiresAt - now > ACCESS_REFRESH_BUFFER_MS) {
|
||||
return mcpAuth
|
||||
}
|
||||
|
||||
// Refresh token also expired → user must re-login.
|
||||
if (mcpAuth.refreshExpiresAt - now <= 0) {
|
||||
throw ApiError.unauthorized(
|
||||
'Heicode Manager 登录会话已过期(refresh token 失效)。请退出重新登录。',
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh.
|
||||
const refreshUrl = `${mcpAuth.managerLoginUrl.replace(/\/+$/, '')}/api/auth/refresh`
|
||||
const res = await fetch(refreshUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${mcpAuth.refreshToken}`,
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
}).catch((err) => {
|
||||
throw ApiError.badGateway(
|
||||
`刷新 Manager token 失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
})
|
||||
|
||||
let body: {
|
||||
success?: boolean
|
||||
message?: string | null
|
||||
data?: { token?: string; refreshToken?: string }
|
||||
} = {}
|
||||
try { body = (await res.json()) as typeof body } catch { /* */ }
|
||||
if (!body.success || !body.data?.token || !body.data.refreshToken) {
|
||||
throw ApiError.unauthorized(
|
||||
`刷新 Manager token 失败: ${body.message || `HTTP ${res.status}`}。请退出重新登录。`,
|
||||
)
|
||||
}
|
||||
|
||||
// Re-decode exp claims.
|
||||
const newAccess = body.data.token
|
||||
const newRefresh = body.data.refreshToken
|
||||
const accessExp = decodeJwtExpMs(newAccess)
|
||||
const refreshExp = decodeJwtExpMs(newRefresh)
|
||||
const refreshed: McpAuth = {
|
||||
...mcpAuth,
|
||||
accessToken: newAccess,
|
||||
refreshToken: newRefresh,
|
||||
accessExpiresAt: accessExp ?? Date.now() + 24 * 3600 * 1000,
|
||||
refreshExpiresAt: refreshExp ?? Date.now() + 7 * 24 * 3600 * 1000,
|
||||
}
|
||||
|
||||
// Persist back to providers.json so the next request reuses the new token.
|
||||
await providerService.updateProvider(provider.id, { mcpAuth: refreshed })
|
||||
return refreshed
|
||||
}
|
||||
|
||||
function decodeJwtExpMs(jwt: string): number | null {
|
||||
try {
|
||||
const parts = jwt.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const payloadB64 = parts[1]!
|
||||
const base64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const json = Buffer.from(padded, 'base64').toString('utf-8')
|
||||
const claims = JSON.parse(json) as { exp?: number }
|
||||
if (typeof claims.exp !== 'number') return null
|
||||
return claims.exp * 1000
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export class ApiError extends Error {
|
||||
return new ApiError(400, message, 'BAD_REQUEST')
|
||||
}
|
||||
|
||||
static unauthorized(message: string) {
|
||||
return new ApiError(401, message, 'UNAUTHORIZED')
|
||||
}
|
||||
|
||||
static notFound(message: string) {
|
||||
return new ApiError(404, message, 'NOT_FOUND')
|
||||
}
|
||||
@@ -27,6 +31,10 @@ export class ApiError extends Error {
|
||||
static internal(message: string) {
|
||||
return new ApiError(500, message, 'INTERNAL_ERROR')
|
||||
}
|
||||
|
||||
static badGateway(message: string) {
|
||||
return new ApiError(502, message, 'BAD_GATEWAY')
|
||||
}
|
||||
}
|
||||
|
||||
export function errorResponse(error: unknown): Response {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { handleSkillsApi } from './api/skills.js'
|
||||
import { handleComputerUseApi } from './api/computer-use.js'
|
||||
import { handleHahaOAuthApi } from './api/haha-oauth.js'
|
||||
import { handleHeicodeAuthApi } from './api/heicode-auth.js'
|
||||
import { handleHeicodeResourcesApi } from './api/heicode-resources.js'
|
||||
import { handleMcpApi } from './api/mcp.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
@@ -76,6 +77,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'heicode-auth':
|
||||
return handleHeicodeAuthApi(req, url, segments)
|
||||
|
||||
case 'heicode-resources':
|
||||
return handleHeicodeResourcesApi(req, url, segments)
|
||||
|
||||
case 'adapters':
|
||||
return handleAdaptersApi(req, url, segments)
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ export class ProviderService {
|
||||
apiFormat: input.apiFormat ?? 'anthropic',
|
||||
models: input.models,
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
...(input.mcpAuth !== undefined && { mcpAuth: input.mcpAuth }),
|
||||
}
|
||||
|
||||
index.providers.push(provider)
|
||||
@@ -190,6 +191,7 @@ export class ProviderService {
|
||||
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
|
||||
...(input.models !== undefined && { models: input.models }),
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
...(input.mcpAuth !== undefined && { mcpAuth: input.mcpAuth }),
|
||||
}
|
||||
|
||||
index.providers[idx] = updated
|
||||
|
||||
@@ -21,6 +21,27 @@ export const ModelMappingSchema = z.object({
|
||||
opus: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Optional Heicode Manager (mcp-server) JWT pair, populated when the user
|
||||
* logs in via email/password through the Path-A flow. Used by the local
|
||||
* server when proxying to mcp-server's P1 (resource binding) endpoints.
|
||||
*/
|
||||
export const McpAuthSchema = z.object({
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string(),
|
||||
/** Unix epoch milliseconds; access token expires at this time. */
|
||||
accessExpiresAt: z.number(),
|
||||
/** Unix epoch milliseconds; refresh token expires at this time. */
|
||||
refreshExpiresAt: z.number(),
|
||||
/** Manager base URL the tokens were issued from (used for /api/auth/refresh). */
|
||||
managerLoginUrl: z.string(),
|
||||
/** Manager-side user.id. */
|
||||
userId: z.string().optional(),
|
||||
/** Manager-side channelId for this user. */
|
||||
channelId: z.string().optional(),
|
||||
})
|
||||
export type McpAuth = z.infer<typeof McpAuthSchema>
|
||||
|
||||
export const SavedProviderSchema = z.object({
|
||||
id: z.string(),
|
||||
presetId: z.string(),
|
||||
@@ -30,6 +51,7 @@ export const SavedProviderSchema = z.object({
|
||||
apiFormat: ApiFormatSchema.default('anthropic'),
|
||||
models: ModelMappingSchema,
|
||||
notes: z.string().optional(),
|
||||
mcpAuth: McpAuthSchema.optional(),
|
||||
})
|
||||
|
||||
export const ProvidersIndexSchema = z.object({
|
||||
@@ -45,6 +67,7 @@ export const CreateProviderSchema = z.object({
|
||||
apiFormat: ApiFormatSchema.default('anthropic'),
|
||||
models: ModelMappingSchema,
|
||||
notes: z.string().optional(),
|
||||
mcpAuth: McpAuthSchema.optional(),
|
||||
})
|
||||
|
||||
export const UpdateProviderSchema = z.object({
|
||||
@@ -54,6 +77,7 @@ export const UpdateProviderSchema = z.object({
|
||||
apiFormat: ApiFormatSchema.optional(),
|
||||
models: ModelMappingSchema.optional(),
|
||||
notes: z.string().optional(),
|
||||
mcpAuth: McpAuthSchema.optional(),
|
||||
})
|
||||
|
||||
export const TestProviderSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user