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:
2026-05-07 18:04:59 +08:00
co-authored by Claude Opus 4.7
parent d1db2c1501
commit c0363be850
11 changed files with 623 additions and 43 deletions
@@ -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.
}
+13 -3
View File
@@ -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',
+13 -3
View File
@@ -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',
+162 -36
View File
@@ -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 }),
}))
+56 -1
View File
@@ -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)
+193
View File
@@ -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 {
+4
View File
@@ -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
+24
View File
@@ -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({