From 9efa9996575ff92d76178bf7cae6d42da58f68b6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 29 Jul 2026 18:35:27 +0800 Subject: [PATCH 1/5] feat(kap-server): expose the managed-account profile at GET /oauth/userinfo --- .changeset/managed-userinfo-endpoint.md | 5 + packages/agent-core-v2/src/app/auth/auth.ts | 6 + .../agent-core-v2/src/app/auth/authService.ts | 16 + .../src/app/auth/oauthProtocol.ts | 50 +++ .../agent-core-v2/test/app/auth/auth.test.ts | 26 ++ packages/kap-server/src/routes/oauth.ts | 66 +++- .../apiSurface.snapshot.test.ts.snap | 4 + packages/kap-server/test/modelCatalog.test.ts | 1 + packages/kap-server/test/oauthUsage.test.ts | 128 ++++++++ packages/oauth/src/index.ts | 9 + packages/oauth/src/managed-userinfo.ts | 176 ++++++++++ packages/oauth/src/toolkit.ts | 41 +++ packages/oauth/test/managed-userinfo.test.ts | 300 ++++++++++++++++++ packages/oauth/test/toolkit.test.ts | 64 ++++ 14 files changed, 888 insertions(+), 4 deletions(-) create mode 100644 .changeset/managed-userinfo-endpoint.md create mode 100644 packages/oauth/src/managed-userinfo.ts create mode 100644 packages/oauth/test/managed-userinfo.test.ts diff --git a/.changeset/managed-userinfo-endpoint.md b/.changeset/managed-userinfo-endpoint.md new file mode 100644 index 0000000000..474a41ccbd --- /dev/null +++ b/.changeset/managed-userinfo-endpoint.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add an account profile endpoint to the server REST API so the web UI can show the signed-in user's profile. diff --git a/packages/agent-core-v2/src/app/auth/auth.ts b/packages/agent-core-v2/src/app/auth/auth.ts index b556770038..812b32677d 100644 --- a/packages/agent-core-v2/src/app/auth/auth.ts +++ b/packages/agent-core-v2/src/app/auth/auth.ts @@ -11,6 +11,7 @@ */ import type { + AuthManagedUserInfoResult, AuthManagedUsageResult, BearerTokenProvider, KimiOAuthLoginOptions, @@ -47,6 +48,7 @@ export interface IOAuthService { status(provider?: string): Promise; refreshOAuthProviderModels(): Promise; getManagedUsage(provider?: string): Promise; + getManagedUserInfo(provider?: string): Promise; resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined; getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise; } @@ -68,6 +70,10 @@ export interface IOAuthToolkit { providerName?: string, options?: { readonly oauthRef?: KimiOAuthTokenRef; readonly baseUrl?: string }, ): Promise; + getManagedUserInfo( + providerName?: string, + options?: { readonly oauthRef?: KimiOAuthTokenRef; readonly baseUrl?: string }, + ): Promise; } export const IOAuthToolkit: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 71b6c71c22..209f99876f 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -27,6 +27,7 @@ import { resolveKimiCodeLoginAuth, resolveKimiCodeOAuthRef, resolveKimiCodeRuntimeAuth, + type AuthManagedUserInfoResult, type AuthManagedUsageResult, type BearerTokenProvider, type DeviceAuthorization, @@ -279,6 +280,21 @@ export class OAuthService extends Disposable implements IOAuthService { }); } + getManagedUserInfo(provider = KIMI_CODE_PROVIDER_NAME): Promise { + // Same resolution path as the managed usage fetch: env-aware base url + + // oauth ref, so a self-hosted/proxied login environment reports its own + // profile endpoint. The toolkit handles token freshness and error mapping. + const configured = this.providerService.get(provider); + const auth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: configured?.baseUrl, + configuredOAuthRef: configured?.oauth, + }); + return this.toolkit.getManagedUserInfo(provider, { + oauthRef: auth.oauthRef, + baseUrl: auth.baseUrl, + }); + } + refreshOAuthProviderModels(): Promise { const run = this.refreshChain.then(() => this.doRefreshOAuthProviderModels()); this.refreshChain = run.then( diff --git a/packages/agent-core-v2/src/app/auth/oauthProtocol.ts b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts index 1064217207..da4129b95a 100644 --- a/packages/agent-core-v2/src/app/auth/oauthProtocol.ts +++ b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts @@ -158,3 +158,53 @@ export const managedUsageResultSchema = z.discriminatedUnion('kind', [ managedUsageErrorSchema, ]); export type ManagedUsageResult = z.infer; + +// --------------------------------------------------------------------------- +// Managed-account profile (`GET /v1/oauth/userinfo`) — mirrors the toolkit's +// `AuthManagedUserInfoResult` (camelCase domain model → snake_case wire DTO). +// --------------------------------------------------------------------------- + +export const managedUserInfoPhoneSchema = z.object({ + country_code: z.string(), + number: z.string(), +}); +export type ManagedUserInfoPhone = z.infer; + +export const managedUserInfoSchema = z.object({ + user_id: z.string(), + nickname: z.string(), + status: z.string(), + region: z.string(), + user_level: z.number().int(), + user_level_name: z.string(), + domain: z.number().int(), + domain_name: z.string(), + global_id: z.string().optional(), + bio: z.string().optional(), + avatar: z.string().optional(), + username: z.string().optional(), + email: z.string().optional(), + phone: managedUserInfoPhoneSchema.optional(), + created_time: z.string().optional(), + last_login_time: z.string().optional(), +}); +export type ManagedUserInfo = z.infer; + +export const managedUserInfoOkSchema = z.object({ + kind: z.literal('ok'), + user_info: managedUserInfoSchema, +}); +export type ManagedUserInfoOk = z.infer; + +export const managedUserInfoErrorSchema = z.object({ + kind: z.literal('error'), + message: z.string(), + status: z.number().int().optional(), +}); +export type ManagedUserInfoError = z.infer; + +export const managedUserInfoResultSchema = z.discriminatedUnion('kind', [ + managedUserInfoOkSchema, + managedUserInfoErrorSchema, +]); +export type ManagedUserInfoResult = z.infer; diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index d0770af6f9..1b82fe0276 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -79,6 +79,7 @@ interface FakeToolkit { readonly getCachedAccessToken: ReturnType; readonly tokenProvider: ReturnType; readonly getManagedUsage: ReturnType; + readonly getManagedUserInfo: ReturnType; } describe('OAuthService', () => { @@ -155,6 +156,7 @@ describe('OAuthService', () => { getCachedAccessToken: vi.fn().mockResolvedValue(undefined), tokenProvider: vi.fn().mockReturnValue({ getAccessToken: async () => 'access-token' }), getManagedUsage: vi.fn().mockResolvedValue({ kind: 'error', message: 'not configured' }), + getManagedUserInfo: vi.fn().mockResolvedValue({ kind: 'error', message: 'not configured' }), }; ix = createServices(disposables, { base: [registerBootstrapServices, registerTelemetryServices], @@ -693,6 +695,30 @@ describe('OAuthService', () => { }); }); + it('getManagedUserInfo resolves the managed runtime auth and delegates to the toolkit', async () => { + const userInfo = { + kind: 'ok' as const, + userInfo: { + userId: 'u_1', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + userLevel: 30, + userLevelName: 'Vivace', + domain: 1, + domainName: 'DOMAIN_EXAMPLE', + }, + }; + toolkit.getManagedUserInfo.mockResolvedValue(userInfo); + const svc = createService(); + + await expect(svc.getManagedUserInfo(OAUTH_PROVIDER)).resolves.toBe(userInfo); + expect(toolkit.getManagedUserInfo).toHaveBeenCalledWith(OAUTH_PROVIDER, { + oauthRef: EXAMPLE_COM_SCOPED_REF, + baseUrl: 'https://api.example.com', + }); + }); + it('refreshOAuthProviderModels returns an empty result when no Kimi Code provider is configured', async () => { providers = { [NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' } }; const svc = createService(); diff --git a/packages/kap-server/src/routes/oauth.ts b/packages/kap-server/src/routes/oauth.ts index edcc5b89bf..298687fdbb 100644 --- a/packages/kap-server/src/routes/oauth.ts +++ b/packages/kap-server/src/routes/oauth.ts @@ -1,10 +1,11 @@ /** * `/oauth/*` REST routes. * - * POST /oauth/login start a device-code flow → OAuthFlowStart - * GET /oauth/login poll current flow state → OAuthFlowSnapshot | null - * DELETE /oauth/login cancel pending flow → { cancelled, status } - * POST /oauth/logout logout → { logged_out, provider } + * POST /oauth/login start a device-code flow → OAuthFlowStart + * GET /oauth/login poll current flow state → OAuthFlowSnapshot | null + * DELETE /oauth/login cancel pending flow → { cancelled, status } + * POST /oauth/logout logout → { logged_out, provider } + * GET /oauth/userinfo managed-account profile → ManagedUserInfoResult * * Backed by the v2 `IOAuthService` (Core scope), which already returns the * protocol wire types, so the handlers only swap the v1 accessor @@ -13,11 +14,13 @@ import { IOAuthService, type Scope } from '@moonshot-ai/agent-core-v2'; import { + managedUserInfoResultSchema, managedUsageResultSchema, oauthFlowSnapshotSchema, oauthFlowStartSchema, oauthLoginCancelResponseSchema, oauthLogoutResponseSchema, + type ManagedUserInfoResult, type ManagedUsageResult, type UsageRow, } from '@moonshot-ai/agent-core-v2/app/auth/oauthProtocol'; @@ -175,6 +178,27 @@ export function registerOAuthRoutes(app: RouteHost, core: Scope): void { usageRoute.options, usageRoute.handler as Parameters[2], ); + + // GET /oauth/userinfo — managed-account profile ------------------------------ + const userInfoRoute = defineRoute( + { + method: 'GET', + path: '/oauth/userinfo', + querystring: oauthLoginQuerySchema, + success: { data: managedUserInfoResultSchema }, + description: 'Get the managed account profile', + tags: ['auth'], + }, + async (req, reply) => { + const result = await core.accessor.get(IOAuthService).getManagedUserInfo(req.query.provider); + reply.send(okEnvelope(toWireUserInfo(result), req.id)); + }, + ); + app.get( + userInfoRoute.path, + userInfoRoute.options, + userInfoRoute.handler as Parameters[2], + ); } /** Domain (camelCase) → wire (snake_case) mapping for the usage payload. */ @@ -218,3 +242,37 @@ function toWireUsageRow(row: DomainUsageRow): UsageRow { reset_at: row.resetAt, }; } + +/** Domain (camelCase) → wire (snake_case) mapping for the profile payload. */ +function toWireUserInfo(result: ManagedUserInfoDomainResult): ManagedUserInfoResult { + if (result.kind === 'error') { + return { kind: 'error', message: result.message, status: result.status }; + } + const userInfo = result.userInfo; + return { + kind: 'ok', + user_info: { + user_id: userInfo.userId, + nickname: userInfo.nickname, + status: userInfo.status, + region: userInfo.region, + user_level: userInfo.userLevel, + user_level_name: userInfo.userLevelName, + domain: userInfo.domain, + domain_name: userInfo.domainName, + global_id: userInfo.globalId, + bio: userInfo.bio, + avatar: userInfo.avatar, + username: userInfo.username, + email: userInfo.email, + phone: + userInfo.phone === undefined + ? undefined + : { country_code: userInfo.phone.countryCode, number: userInfo.phone.number }, + created_time: userInfo.createdTime, + last_login_time: userInfo.lastLoginTime, + }, + }; +} + +type ManagedUserInfoDomainResult = Awaited>; diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 2d824ef41e..df90320865 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -120,6 +120,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/oauth/usage", ], + [ + "GET", + "/api/v1/oauth/userinfo", + ], [ "GET", "/api/v1/providers", diff --git a/packages/kap-server/test/modelCatalog.test.ts b/packages/kap-server/test/modelCatalog.test.ts index c617872ad4..e5735eea42 100644 --- a/packages/kap-server/test/modelCatalog.test.ts +++ b/packages/kap-server/test/modelCatalog.test.ts @@ -320,6 +320,7 @@ describe('server-v2 /api/v1 model/provider catalog', () => { status: async () => ({ loggedIn: false }), refreshOAuthProviderModels, getManagedUsage: async () => ({ kind: 'error' as const, message: 'unused' }), + getManagedUserInfo: async () => ({ kind: 'error' as const, message: 'unused' }), resolveTokenProvider: () => undefined, getCachedAccessToken: async () => undefined, }; diff --git a/packages/kap-server/test/oauthUsage.test.ts b/packages/kap-server/test/oauthUsage.test.ts index 8db52b5165..9445c5c5ae 100644 --- a/packages/kap-server/test/oauthUsage.test.ts +++ b/packages/kap-server/test/oauthUsage.test.ts @@ -8,7 +8,9 @@ import { type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; import { + managedUserInfoResultSchema, managedUsageResultSchema, + type ManagedUserInfoResult, type ManagedUsageResult, } from '@moonshot-ai/agent-core-v2/app/auth/oauthProtocol'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -59,6 +61,7 @@ describe('server-v2 GET /api/v1/oauth/usage', () => { status: async () => ({ loggedIn: false }), refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }), getManagedUsage, + getManagedUserInfo: async () => ({ kind: 'error' as const, message: 'unused' }), resolveTokenProvider: () => undefined, getCachedAccessToken: async () => undefined, }; @@ -150,3 +153,128 @@ describe('server-v2 GET /api/v1/oauth/usage', () => { expect(getManagedUsage).toHaveBeenCalledWith('managed:kimi-code'); }); }); + +describe('server-v2 GET /api/v1/oauth/userinfo', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-oauth-userinfo-')); + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + function oauthStub(getManagedUserInfo: IOAuthServiceType['getManagedUserInfo']): IOAuthServiceType { + return { + _serviceBrand: undefined, + startLogin: async () => { + throw new Error('unused'); + }, + getFlow: () => undefined, + cancelLogin: async () => { + throw new Error('unused'); + }, + logout: async () => { + throw new Error('unused'); + }, + status: async () => ({ loggedIn: false }), + refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }), + getManagedUsage: async () => ({ kind: 'error' as const, message: 'unused' }), + getManagedUserInfo, + resolveTokenProvider: () => undefined, + getCachedAccessToken: async () => undefined, + }; + } + + async function boot(seeds: ScopeSeed): Promise { + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds, + }); + base = `http://127.0.0.1:${server.port}`; + } + + async function getUserInfo(query = ''): Promise { + const res = await fetch(`${base}/api/v1/oauth/userinfo${query}`, { + headers: authHeaders(server as RunningServer), + } as never); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + return managedUserInfoResultSchema.parse(body.data); + } + + it('maps the ok profile payload to the snake_case wire shape', async () => { + const getManagedUserInfo = vi.fn(async () => ({ + kind: 'ok' as const, + userInfo: { + userId: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + userLevel: 30, + userLevelName: 'Vivace', + domain: 1, + domainName: 'DOMAIN_EXAMPLE', + globalId: 'u_123', + avatar: 'https://example.com/avatar.png', + username: 'moonwalker2333', + email: 'user@example.com', + phone: { countryCode: '86', number: '176****0000' }, + createdTime: '2026-06-11T13:26:47.561184Z', + lastLoginTime: '2026-07-16T03:12:03.033412Z', + }, + })); + await boot([[IOAuthService, oauthStub(getManagedUserInfo)]] as unknown as ScopeSeed); + + expect(await getUserInfo()).toEqual({ + kind: 'ok', + user_info: { + user_id: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + user_level: 30, + user_level_name: 'Vivace', + domain: 1, + domain_name: 'DOMAIN_EXAMPLE', + global_id: 'u_123', + avatar: 'https://example.com/avatar.png', + username: 'moonwalker2333', + email: 'user@example.com', + phone: { country_code: '86', number: '176****0000' }, + created_time: '2026-06-11T13:26:47.561184Z', + last_login_time: '2026-07-16T03:12:03.033412Z', + }, + }); + }); + + it('passes through the error payload and forwards the provider query', async () => { + const getManagedUserInfo = vi.fn(async (_provider?: string) => ({ + kind: 'error' as const, + message: 'Authorization failed.', + status: 401, + })); + await boot([[IOAuthService, oauthStub(getManagedUserInfo)]] as unknown as ScopeSeed); + + expect(await getUserInfo('?provider=managed%3Akimi-code')).toEqual({ + kind: 'error', + message: 'Authorization failed.', + status: 401, + }); + expect(getManagedUserInfo).toHaveBeenCalledWith('managed:kimi-code'); + }); +}); diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 3cea3ecef5..a4256057e2 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -77,6 +77,14 @@ export type { ProvisionManagedKimiCodeConfigOptions, } from './managed-kimi-code'; +export { fetchManagedUserInfo, kimiCodeUserInfoUrl, parseManagedUserInfoPayload } from './managed-userinfo'; +export type { + FetchManagedUserInfoError, + FetchManagedUserInfoResult, + ManagedUserInfo, + ManagedUserInfoPhone, +} from './managed-userinfo'; + export { fetchManagedUsage, formatDuration, @@ -153,6 +161,7 @@ export type { export { KimiOAuthToolkit, resolveKimiTokenStorageName } from './toolkit'; export type { + AuthManagedUserInfoResult, AuthManagedUsageResult, AuthProviderStatus, AuthStatus, diff --git a/packages/oauth/src/managed-userinfo.ts b/packages/oauth/src/managed-userinfo.ts new file mode 100644 index 0000000000..eb196ce525 --- /dev/null +++ b/packages/oauth/src/managed-userinfo.ts @@ -0,0 +1,176 @@ +/** + * Managed-platform profile fetch / parse. + * + * Only `managed:kimi-code` is supported today. The platform exposes a + * `/me` endpoint that returns a payload of the shape: + * + * { + * "user_id": "u_123", + * "nickname": "moonwalker", + * "status": "USER_STATUS_NORMAL", + * "region": "REGION_CN", + * "user_level": 30, + * "user_level_name": "Vivace", + * "domain": 1, + * "domain_name": "DOMAIN_EXAMPLE", + * "global_id": "u_123", + * "avatar": "https://example.com/avatar.png", + * "email": "user@example.com", + * "phone": { "country_code": "86", "number": "176****0000" }, + * "created_time": "2026-06-11T13:26:47.561184Z", + * "last_login_time": "2026-07-16T03:12:03.033412Z" + * } + * + * `user_id` / `nickname` / `status` / `region` / `user_level` / + * `user_level_name` / `domain` / `domain_name` are always serialized + * (possibly as zero values); the rest are optional (`phone` a nested + * record, the timestamps RFC3339 strings passed through verbatim). The + * parser normalizes the payload into a structured, camelCase domain + * model; presentation is left to the consumer. + */ + +import { readApiErrorMessage } from './api-error'; +import { kimiCodeBaseUrl } from './managed-usage'; +import { isRecord } from './utils'; + +// The cloud path stays `/me` (owned by the backend); only the local +// naming moved to "userinfo". +export function kimiCodeUserInfoUrl(): string { + return `${kimiCodeBaseUrl()}/me`; +} + +export interface ManagedUserInfoPhone { + readonly countryCode: string; + readonly number: string; +} + +export interface ManagedUserInfo { + readonly userId: string; + readonly nickname: string; + readonly status: string; + readonly region: string; + readonly userLevel: number; + readonly userLevelName: string; + readonly domain: number; + readonly domainName: string; + readonly globalId?: string; + readonly bio?: string; + readonly avatar?: string; + readonly username?: string; + readonly email?: string; + readonly phone?: ManagedUserInfoPhone; + /** Backend RFC3339 timestamp, passed through verbatim. */ + readonly createdTime?: string; + /** Backend RFC3339 timestamp, passed through verbatim. */ + readonly lastLoginTime?: string; +} + +/** + * Lenient parse: anything that is not a record, or lacks a `user_id` + * string, is malformed; every other field degrades independently. + */ +export function parseManagedUserInfoPayload(payload: unknown): ManagedUserInfo | null { + if (!isRecord(payload)) return null; + const userId = stringField(payload, 'user_id'); + if (userId === undefined) return null; + return { + userId, + nickname: stringField(payload, 'nickname') ?? '', + status: stringField(payload, 'status') ?? '', + region: stringField(payload, 'region') ?? '', + userLevel: intField(payload, 'user_level') ?? 0, + userLevelName: stringField(payload, 'user_level_name') ?? '', + domain: intField(payload, 'domain') ?? 0, + domainName: stringField(payload, 'domain_name') ?? '', + globalId: stringField(payload, 'global_id'), + bio: stringField(payload, 'bio'), + avatar: stringField(payload, 'avatar'), + username: stringField(payload, 'username'), + email: stringField(payload, 'email'), + phone: parsePhone(payload['phone']), + createdTime: stringField(payload, 'created_time'), + lastLoginTime: stringField(payload, 'last_login_time'), + }; +} + +function parsePhone(raw: unknown): ManagedUserInfoPhone | undefined { + if (!isRecord(raw)) return undefined; + const countryCode = stringField(raw, 'country_code') ?? ''; + const number = stringField(raw, 'number') ?? ''; + if (countryCode === '' && number === '') return undefined; + return { countryCode, number }; +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function intField(record: Record, key: string): number | undefined { + const value = record[key]; + if (typeof value === 'number') { + return Number.isFinite(value) ? Math.trunc(value) : undefined; + } + if (typeof value === 'string') { + const n = Number(value); + return Number.isFinite(n) ? Math.trunc(n) : undefined; + } + return undefined; +} + +// ── HTTP fetch ──────────────────────────────────────────────────────── + +export interface FetchManagedUserInfoResult { + readonly kind: 'ok'; + readonly userInfo: ManagedUserInfo; +} + +export interface FetchManagedUserInfoError { + readonly kind: 'error'; + readonly status?: number; + readonly message: string; +} + +export async function fetchManagedUserInfo( + url: string, + accessToken: string, + opts: { timeoutMs?: number } = {}, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, opts.timeoutMs ?? 8000); + try { + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + signal: controller.signal, + }); + if (!res.ok) { + const status = res.status; + const hint = + status === 401 + ? 'Authorization failed. Please check your API key (try /login).' + : status === 404 + ? 'Profile endpoint not available. Try Kimi For Coding.' + : `Failed to fetch profile: HTTP ${String(status)}`; + return { kind: 'error', status, message: await readApiErrorMessage(res, hint) }; + } + const json: unknown = await res.json(); + const me = parseManagedUserInfoPayload(json); + if (me === null) { + return { kind: 'error', message: 'Failed to fetch profile: malformed response.' }; + } + return { kind: 'ok', userInfo: me }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { kind: 'error', message: 'Failed to fetch profile: request timed out.' }; + } + const msg = error instanceof Error ? error.message : String(error); + return { kind: 'error', message: `Failed to fetch profile: ${msg}` }; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 0229db7b03..76dc1eaaac 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -31,6 +31,12 @@ import { type ManagedKimiCodeProvisionResult, type ManagedKimiConfigAdapter, } from './managed-kimi-code'; +import { + fetchManagedUserInfo, + kimiCodeUserInfoUrl, + type FetchManagedUserInfoError, + type ManagedUserInfo, +} from './managed-userinfo'; import { fetchManagedUsage, kimiCodeUsageUrl, @@ -101,6 +107,13 @@ export type AuthManagedUsageResult = } | FetchManagedUsageError; +export type AuthManagedUserInfoResult = + | { + readonly kind: 'ok'; + readonly userInfo: ManagedUserInfo; + } + | FetchManagedUserInfoError; + export class KimiOAuthToolkit { private readonly homeDir: string; private readonly identity: KimiHostIdentity | undefined; @@ -302,6 +315,29 @@ export class KimiOAuthToolkit { } } + async getManagedUserInfo( + providerName?: string | undefined, + options: { + readonly oauthRef?: KimiOAuthTokenRef | undefined; + readonly baseUrl?: string | undefined; + } = {}, + ): Promise { + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; + try { + const accessToken = await this.ensureFresh(name, { + oauthRef: options.oauthRef ?? this.defaultOAuthRef(options.baseUrl), + }); + const result = await fetchManagedUserInfo(managedUserInfoUrl(options.baseUrl), accessToken); + if (result.kind === 'error') return result; + return { kind: 'ok', userInfo: result.userInfo }; + } catch (error) { + return { + kind: 'error', + message: error instanceof Error ? error.message : String(error), + }; + } + } + async submitFeedback( body: SubmitFeedbackBody, providerName?: string | undefined, @@ -464,6 +500,11 @@ function managedUsageUrl(baseUrl: string | undefined): string { return `${baseUrl.replace(/\/+$/, '')}/usages`; } +function managedUserInfoUrl(baseUrl: string | undefined): string { + if (baseUrl === undefined) return kimiCodeUserInfoUrl(); + return `${baseUrl.replace(/\/+$/, '')}/me`; +} + function managedFeedbackUrl(baseUrl: string | undefined): string { return kimiCodeFeedbackUrl(baseUrl); } diff --git a/packages/oauth/test/managed-userinfo.test.ts b/packages/oauth/test/managed-userinfo.test.ts new file mode 100644 index 0000000000..5c0e53e88f --- /dev/null +++ b/packages/oauth/test/managed-userinfo.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; + +import { + fetchManagedUserInfo, + kimiCodeUserInfoUrl, + parseManagedUserInfoPayload, +} from '../src/managed-userinfo'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('kimiCodeUserInfoUrl', () => { + it('follows the KIMI_CODE_BASE_URL override', () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://gw.example.com/'); + expect(kimiCodeUserInfoUrl()).toBe('https://gw.example.com/me'); + }); +}); + +describe('parseManagedUserInfoPayload', () => { + it('returns null when the payload is not a record', () => { + expect(parseManagedUserInfoPayload(null)).toBeNull(); + expect(parseManagedUserInfoPayload('nope')).toBeNull(); + expect(parseManagedUserInfoPayload([{ user_id: 'u_1' }])).toBeNull(); + }); + + it('returns null when user_id is missing or not a string', () => { + expect(parseManagedUserInfoPayload({ nickname: 'moonwalker' })).toBeNull(); + expect(parseManagedUserInfoPayload({ user_id: 42 })).toBeNull(); + expect(parseManagedUserInfoPayload({ user_id: '' })).toBeNull(); + }); + + it('parses the full profile payload into the camelCase domain model', () => { + const parsed = parseManagedUserInfoPayload({ + user_id: 'u_123', + global_id: 'u_123', + nickname: 'moonwalker', + avatar: 'https://example.com/avatar.png', + bio: 'to the moon', + username: 'moonwalker2333', + email: 'user@example.com', + user_level: 30, + user_level_name: 'Vivace', + domain: 1, + domain_name: 'DOMAIN_EXAMPLE', + phone: { country_code: '86', number: '176****0000' }, + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + created_time: '2026-06-11T13:26:47.561184Z', + last_login_time: '2026-07-16T03:12:03.033412Z', + }); + expect(parsed).toEqual({ + userId: 'u_123', + globalId: 'u_123', + nickname: 'moonwalker', + avatar: 'https://example.com/avatar.png', + bio: 'to the moon', + username: 'moonwalker2333', + email: 'user@example.com', + userLevel: 30, + userLevelName: 'Vivace', + domain: 1, + domainName: 'DOMAIN_EXAMPLE', + phone: { countryCode: '86', number: '176****0000' }, + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + createdTime: '2026-06-11T13:26:47.561184Z', + lastLoginTime: '2026-07-16T03:12:03.033412Z', + }); + }); + + it('keeps only the required fields of a minimal payload', () => { + const parsed = parseManagedUserInfoPayload({ + user_id: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + user_level: 30, + user_level_name: 'Vivace', + domain: 1, + domain_name: 'DOMAIN_EXAMPLE', + }); + expect(parsed).toEqual({ + userId: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + userLevel: 30, + userLevelName: 'Vivace', + domain: 1, + domainName: 'DOMAIN_EXAMPLE', + globalId: undefined, + bio: undefined, + avatar: undefined, + username: undefined, + email: undefined, + phone: undefined, + createdTime: undefined, + lastLoginTime: undefined, + }); + }); + + it('degrades missing required fields to zero values', () => { + const parsed = parseManagedUserInfoPayload({ user_id: 'u_123' }); + expect(parsed).toMatchObject({ + userId: 'u_123', + nickname: '', + status: '', + region: '', + userLevel: 0, + userLevelName: '', + domain: 0, + domainName: '', + }); + }); + + it('drops a phone record whose fields are all empty or non-string', () => { + expect( + parseManagedUserInfoPayload({ user_id: 'u_1', phone: { country_code: 86 } })?.phone, + ).toBeUndefined(); + expect(parseManagedUserInfoPayload({ user_id: 'u_1', phone: '86' })?.phone).toBeUndefined(); + }); + + it('keeps a partially populated phone record', () => { + const parsed = parseManagedUserInfoPayload({ + user_id: 'u_1', + phone: { number: '176****0000' }, + }); + expect(parsed?.phone).toEqual({ countryCode: '', number: '176****0000' }); + }); + + it('accepts numeric strings for the level fields and truncates fractions', () => { + const parsed = parseManagedUserInfoPayload({ + user_id: 'u_1', + user_level: '30', + domain: 1.9, + }); + expect(parsed).toMatchObject({ userLevel: 30, domain: 1 }); + }); + + it('degrades non-numeric level fields to zero', () => { + const parsed = parseManagedUserInfoPayload({ + user_id: 'u_1', + user_level: 'thirty', + domain: Number.NaN, + }); + expect(parsed).toMatchObject({ userLevel: 0, domain: 0 }); + }); + + it('keeps email only when it is a non-empty string', () => { + expect( + parseManagedUserInfoPayload({ user_id: 'u_1', email: 'user@example.com' })?.email, + ).toBe('user@example.com'); + expect(parseManagedUserInfoPayload({ user_id: 'u_1', email: '' })?.email).toBeUndefined(); + expect(parseManagedUserInfoPayload({ user_id: 'u_1', email: 42 })?.email).toBeUndefined(); + }); +}); + +describe('fetchManagedUserInfo', () => { + it('sends only Authorization and Accept headers', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + user_id: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + user_level: 30, + user_level_name: 'Vivace', + domain: 1, + domain_name: 'DOMAIN_EXAMPLE', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchManagedUserInfo('https://api.example/me', 'access-token')).resolves.toEqual({ + kind: 'ok', + userInfo: { + userId: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + userLevel: 30, + userLevelName: 'Vivace', + domain: 1, + domainName: 'DOMAIN_EXAMPLE', + globalId: undefined, + bio: undefined, + avatar: undefined, + username: undefined, + email: undefined, + phone: undefined, + createdTime: undefined, + lastLoginTime: undefined, + }, + }); + + const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; + const init = calls[0]?.[1] ?? {}; + const headers = new Headers((init.headers ?? {}) as Record); + expect(headers.get('authorization')).toBe('Bearer access-token'); + expect(headers.get('accept')).toBe('application/json'); + expect(headers.get('user-agent')).toBeNull(); + expect(headers.get('x-msh-platform')).toBeNull(); + }); + + it('surfaces JSON API error messages with status', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ message: 'token expired' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + const result = await fetchManagedUserInfo('https://api.example/me', 'access-token'); + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') return; + expect(result.status).toBe(401); + expect(result.message).toBe('token expired'); + }); + + it('falls back to the local authorization hint on an empty 401 body', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 401 }))); + + const result = await fetchManagedUserInfo('https://api.example/me', 'access-token'); + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') return; + expect(result.status).toBe(401); + expect(result.message).toBe('Authorization failed. Please check your API key (try /login).'); + }); + + it('falls back to the local profile hint on an empty 404 body', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 }))); + + const result = await fetchManagedUserInfo('https://api.example/me', 'access-token'); + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') return; + expect(result.status).toBe(404); + expect(result.message).toBe('Profile endpoint not available. Try Kimi For Coding.'); + }); + + it('treats a payload without user_id as malformed', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ nickname: 'moonwalker' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + const result = await fetchManagedUserInfo('https://api.example/me', 'access-token'); + + expect(result).toEqual({ kind: 'error', message: 'Failed to fetch profile: malformed response.' }); + }); + + it('maps an aborted request to a timeout message', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + throw error; + }), + ); + + const result = await fetchManagedUserInfo('https://api.example/me', 'access-token'); + + expect(result).toEqual({ + kind: 'error', + message: 'Failed to fetch profile: request timed out.', + }); + }); + + it('wraps network failures with the profile prefix', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('socket hang up'); + }), + ); + + const result = await fetchManagedUserInfo('https://api.example/me', 'access-token'); + + expect(result).toEqual({ kind: 'error', message: 'Failed to fetch profile: socket hang up' }); + }); +}); diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 56c86b44eb..e785b32ce3 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -650,6 +650,70 @@ describe('KimiOAuthToolkit', () => { }); }); + it('propagates the managed profile response', async () => { + const storage = new MemoryTokenStorage(); + storage.tokens.set('kimi-code', token('access-1')); + const fetchImpl = vi.fn(async (_input: unknown, _init?: RequestInit) => + new Response( + JSON.stringify({ + user_id: 'u_123', + global_id: 'u_123', + nickname: 'moonwalker', + avatar: 'https://example.com/avatar.png', + phone: { country_code: '86', number: '176****0000' }, + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + created_time: '2026-06-11T13:26:47.561184Z', + last_login_time: '2026-07-16T03:12:03.033412Z', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof fetch; + vi.stubGlobal('fetch', fetchImpl); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + await expect(toolkit.getManagedUserInfo()).resolves.toMatchObject({ + kind: 'ok', + userInfo: { + userId: 'u_123', + globalId: 'u_123', + nickname: 'moonwalker', + avatar: 'https://example.com/avatar.png', + phone: { countryCode: '86', number: '176****0000' }, + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + createdTime: '2026-06-11T13:26:47.561184Z', + lastLoginTime: '2026-07-16T03:12:03.033412Z', + }, + }); + }); + + it('normalizes managed profile fetch errors into the error result', async () => { + const storage = new MemoryTokenStorage(); + storage.tokens.set('kimi-code', token('access-1')); + const fetchImpl = vi.fn( + async () => new Response('', { status: 401 }), + ) as unknown as typeof fetch; + vi.stubGlobal('fetch', fetchImpl); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + await expect(toolkit.getManagedUserInfo()).resolves.toEqual({ + kind: 'error', + status: 401, + message: 'Authorization failed. Please check your API key (try /login).', + }); + }); + it('removes managed config on logout when an adapter supports cleanup', async () => { const storage = new MemoryTokenStorage(); storage.tokens.set('kimi-code', token('access-1')); From 0a9c1e14b1b7fea2f04967eec27c59b16f1a7b6f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 29 Jul 2026 19:03:06 +0800 Subject: [PATCH 2/5] refactor(oauth): serve the userinfo profile as the camelCase domain type end to end --- .../src/app/auth/oauthProtocol.ts | 56 +++------------ packages/kap-server/src/routes/oauth.ts | 37 +--------- packages/kap-server/test/oauthUsage.test.ts | 20 +++--- packages/oauth/package.json | 3 +- packages/oauth/src/index.ts | 10 ++- packages/oauth/src/managed-userinfo.ts | 70 +++++++++++++------ packages/oauth/src/toolkit.ts | 10 +-- packages/oauth/test/managed-userinfo.test.ts | 55 +++++++++++++++ pnpm-lock.yaml | 5 +- 9 files changed, 141 insertions(+), 125 deletions(-) diff --git a/packages/agent-core-v2/src/app/auth/oauthProtocol.ts b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts index da4129b95a..e3bc888291 100644 --- a/packages/agent-core-v2/src/app/auth/oauthProtocol.ts +++ b/packages/agent-core-v2/src/app/auth/oauthProtocol.ts @@ -4,6 +4,8 @@ * Request/response shapes of the v1 `/oauth/*` endpoints plus the managed * OAuth provider model-refresh response, defined as zod schemas so the * transports validate against the same contract the `IOAuthService` returns. + * New endpoints use the camelCase domain contract owned by the oauth + * package (re-exported below); legacy snake_case schemas stay local. */ import { z } from 'zod'; @@ -160,51 +162,13 @@ export const managedUsageResultSchema = z.discriminatedUnion('kind', [ export type ManagedUsageResult = z.infer; // --------------------------------------------------------------------------- -// Managed-account profile (`GET /v1/oauth/userinfo`) — mirrors the toolkit's -// `AuthManagedUserInfoResult` (camelCase domain model → snake_case wire DTO). +// Managed-account profile (`GET /v1/oauth/userinfo`) — camelCase domain +// contract owned by `@moonshot-ai/kimi-code-oauth` (its zod schemas are the +// single source of truth); re-exported here so transports keep one import +// path. Legacy snake_case endpoints keep their local schemas above. // --------------------------------------------------------------------------- -export const managedUserInfoPhoneSchema = z.object({ - country_code: z.string(), - number: z.string(), -}); -export type ManagedUserInfoPhone = z.infer; - -export const managedUserInfoSchema = z.object({ - user_id: z.string(), - nickname: z.string(), - status: z.string(), - region: z.string(), - user_level: z.number().int(), - user_level_name: z.string(), - domain: z.number().int(), - domain_name: z.string(), - global_id: z.string().optional(), - bio: z.string().optional(), - avatar: z.string().optional(), - username: z.string().optional(), - email: z.string().optional(), - phone: managedUserInfoPhoneSchema.optional(), - created_time: z.string().optional(), - last_login_time: z.string().optional(), -}); -export type ManagedUserInfo = z.infer; - -export const managedUserInfoOkSchema = z.object({ - kind: z.literal('ok'), - user_info: managedUserInfoSchema, -}); -export type ManagedUserInfoOk = z.infer; - -export const managedUserInfoErrorSchema = z.object({ - kind: z.literal('error'), - message: z.string(), - status: z.number().int().optional(), -}); -export type ManagedUserInfoError = z.infer; - -export const managedUserInfoResultSchema = z.discriminatedUnion('kind', [ - managedUserInfoOkSchema, - managedUserInfoErrorSchema, -]); -export type ManagedUserInfoResult = z.infer; +export { + managedUserInfoResultSchema, + type ManagedUserInfoResult, +} from '@moonshot-ai/kimi-code-oauth'; diff --git a/packages/kap-server/src/routes/oauth.ts b/packages/kap-server/src/routes/oauth.ts index 298687fdbb..9625f1e2b4 100644 --- a/packages/kap-server/src/routes/oauth.ts +++ b/packages/kap-server/src/routes/oauth.ts @@ -20,7 +20,6 @@ import { oauthFlowStartSchema, oauthLoginCancelResponseSchema, oauthLogoutResponseSchema, - type ManagedUserInfoResult, type ManagedUsageResult, type UsageRow, } from '@moonshot-ai/agent-core-v2/app/auth/oauthProtocol'; @@ -191,7 +190,7 @@ export function registerOAuthRoutes(app: RouteHost, core: Scope): void { }, async (req, reply) => { const result = await core.accessor.get(IOAuthService).getManagedUserInfo(req.query.provider); - reply.send(okEnvelope(toWireUserInfo(result), req.id)); + reply.send(okEnvelope(result, req.id)); }, ); app.get( @@ -242,37 +241,3 @@ function toWireUsageRow(row: DomainUsageRow): UsageRow { reset_at: row.resetAt, }; } - -/** Domain (camelCase) → wire (snake_case) mapping for the profile payload. */ -function toWireUserInfo(result: ManagedUserInfoDomainResult): ManagedUserInfoResult { - if (result.kind === 'error') { - return { kind: 'error', message: result.message, status: result.status }; - } - const userInfo = result.userInfo; - return { - kind: 'ok', - user_info: { - user_id: userInfo.userId, - nickname: userInfo.nickname, - status: userInfo.status, - region: userInfo.region, - user_level: userInfo.userLevel, - user_level_name: userInfo.userLevelName, - domain: userInfo.domain, - domain_name: userInfo.domainName, - global_id: userInfo.globalId, - bio: userInfo.bio, - avatar: userInfo.avatar, - username: userInfo.username, - email: userInfo.email, - phone: - userInfo.phone === undefined - ? undefined - : { country_code: userInfo.phone.countryCode, number: userInfo.phone.number }, - created_time: userInfo.createdTime, - last_login_time: userInfo.lastLoginTime, - }, - }; -} - -type ManagedUserInfoDomainResult = Awaited>; diff --git a/packages/kap-server/test/oauthUsage.test.ts b/packages/kap-server/test/oauthUsage.test.ts index 9445c5c5ae..161806beac 100644 --- a/packages/kap-server/test/oauthUsage.test.ts +++ b/packages/kap-server/test/oauthUsage.test.ts @@ -217,7 +217,7 @@ describe('server-v2 GET /api/v1/oauth/userinfo', () => { return managedUserInfoResultSchema.parse(body.data); } - it('maps the ok profile payload to the snake_case wire shape', async () => { + it('returns the ok profile payload in the camelCase domain shape', async () => { const getManagedUserInfo = vi.fn(async () => ({ kind: 'ok' as const, userInfo: { @@ -242,22 +242,22 @@ describe('server-v2 GET /api/v1/oauth/userinfo', () => { expect(await getUserInfo()).toEqual({ kind: 'ok', - user_info: { - user_id: 'u_123', + userInfo: { + userId: 'u_123', nickname: 'moonwalker', status: 'USER_STATUS_NORMAL', region: 'REGION_CN', - user_level: 30, - user_level_name: 'Vivace', + userLevel: 30, + userLevelName: 'Vivace', domain: 1, - domain_name: 'DOMAIN_EXAMPLE', - global_id: 'u_123', + domainName: 'DOMAIN_EXAMPLE', + globalId: 'u_123', avatar: 'https://example.com/avatar.png', username: 'moonwalker2333', email: 'user@example.com', - phone: { country_code: '86', number: '176****0000' }, - created_time: '2026-06-11T13:26:47.561184Z', - last_login_time: '2026-07-16T03:12:03.033412Z', + phone: { countryCode: '86', number: '176****0000' }, + createdTime: '2026-06-11T13:26:47.561184Z', + lastLoginTime: '2026-07-16T03:12:03.033412Z', }, }); }); diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 79dbe04cd2..7191e26959 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -40,7 +40,8 @@ "clean": "rm -rf dist" }, "dependencies": { - "proper-lockfile": "^4.1.2" + "proper-lockfile": "^4.1.2", + "zod": "catalog:" }, "devDependencies": { "@types/proper-lockfile": "^4.1.4" diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index a4256057e2..9418deb926 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -77,12 +77,20 @@ export type { ProvisionManagedKimiCodeConfigOptions, } from './managed-kimi-code'; -export { fetchManagedUserInfo, kimiCodeUserInfoUrl, parseManagedUserInfoPayload } from './managed-userinfo'; +export { + fetchManagedUserInfo, + kimiCodeUserInfoUrl, + managedUserInfoPhoneSchema, + managedUserInfoResultSchema, + managedUserInfoSchema, + parseManagedUserInfoPayload, +} from './managed-userinfo'; export type { FetchManagedUserInfoError, FetchManagedUserInfoResult, ManagedUserInfo, ManagedUserInfoPhone, + ManagedUserInfoResult, } from './managed-userinfo'; export { diff --git a/packages/oauth/src/managed-userinfo.ts b/packages/oauth/src/managed-userinfo.ts index eb196ce525..7a21eea4b0 100644 --- a/packages/oauth/src/managed-userinfo.ts +++ b/packages/oauth/src/managed-userinfo.ts @@ -27,8 +27,15 @@ * record, the timestamps RFC3339 strings passed through verbatim). The * parser normalizes the payload into a structured, camelCase domain * model; presentation is left to the consumer. + * + * The camelCase zod schemas below are the single source of truth for the + * domain model and double as the daemon REST wire contract: new daemon + * endpoints speak the camelCase domain shape directly, while legacy + * snake_case endpoints are unaffected. */ +import { z } from 'zod'; + import { readApiErrorMessage } from './api-error'; import { kimiCodeBaseUrl } from './managed-usage'; import { isRecord } from './utils'; @@ -39,31 +46,50 @@ export function kimiCodeUserInfoUrl(): string { return `${kimiCodeBaseUrl()}/me`; } -export interface ManagedUserInfoPhone { - readonly countryCode: string; - readonly number: string; -} +export const managedUserInfoPhoneSchema = z.object({ + countryCode: z.string(), + number: z.string(), +}); +export type ManagedUserInfoPhone = z.infer; -export interface ManagedUserInfo { - readonly userId: string; - readonly nickname: string; - readonly status: string; - readonly region: string; - readonly userLevel: number; - readonly userLevelName: string; - readonly domain: number; - readonly domainName: string; - readonly globalId?: string; - readonly bio?: string; - readonly avatar?: string; - readonly username?: string; - readonly email?: string; - readonly phone?: ManagedUserInfoPhone; +export const managedUserInfoSchema = z.object({ + userId: z.string(), + nickname: z.string(), + status: z.string(), + region: z.string(), + userLevel: z.number().int(), + userLevelName: z.string(), + domain: z.number().int(), + domainName: z.string(), + globalId: z.string().optional(), + bio: z.string().optional(), + avatar: z.string().optional(), + username: z.string().optional(), + email: z.string().optional(), + phone: managedUserInfoPhoneSchema.optional(), /** Backend RFC3339 timestamp, passed through verbatim. */ - readonly createdTime?: string; + createdTime: z.string().optional(), /** Backend RFC3339 timestamp, passed through verbatim. */ - readonly lastLoginTime?: string; -} + lastLoginTime: z.string().optional(), +}); +export type ManagedUserInfo = z.infer; + +const managedUserInfoOkSchema = z.object({ + kind: z.literal('ok'), + userInfo: managedUserInfoSchema, +}); + +const managedUserInfoErrorSchema = z.object({ + kind: z.literal('error'), + message: z.string(), + status: z.number().int().optional(), +}); + +export const managedUserInfoResultSchema = z.discriminatedUnion('kind', [ + managedUserInfoOkSchema, + managedUserInfoErrorSchema, +]); +export type ManagedUserInfoResult = z.infer; /** * Lenient parse: anything that is not a record, or lacks a `user_id` diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 76dc1eaaac..e915382079 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -34,8 +34,7 @@ import { import { fetchManagedUserInfo, kimiCodeUserInfoUrl, - type FetchManagedUserInfoError, - type ManagedUserInfo, + type ManagedUserInfoResult, } from './managed-userinfo'; import { fetchManagedUsage, @@ -107,12 +106,7 @@ export type AuthManagedUsageResult = } | FetchManagedUsageError; -export type AuthManagedUserInfoResult = - | { - readonly kind: 'ok'; - readonly userInfo: ManagedUserInfo; - } - | FetchManagedUserInfoError; +export type AuthManagedUserInfoResult = ManagedUserInfoResult; export class KimiOAuthToolkit { private readonly homeDir: string; diff --git a/packages/oauth/test/managed-userinfo.test.ts b/packages/oauth/test/managed-userinfo.test.ts index 5c0e53e88f..d0ee107573 100644 --- a/packages/oauth/test/managed-userinfo.test.ts +++ b/packages/oauth/test/managed-userinfo.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, it, expect, vi } from 'vitest'; import { fetchManagedUserInfo, kimiCodeUserInfoUrl, + managedUserInfoResultSchema, + managedUserInfoSchema, parseManagedUserInfoPayload, } from '../src/managed-userinfo'; @@ -298,3 +300,56 @@ describe('fetchManagedUserInfo', () => { expect(result).toEqual({ kind: 'error', message: 'Failed to fetch profile: socket hang up' }); }); }); + +describe('managedUserInfoResultSchema', () => { + const fullUserInfo = { + userId: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + userLevel: 30, + userLevelName: 'Vivace', + domain: 1, + domainName: 'DOMAIN_EXAMPLE', + globalId: 'u_123', + avatar: 'https://example.com/avatar.png', + username: 'moonwalker2333', + email: 'user@example.com', + phone: { countryCode: '86', number: '176****0000' }, + createdTime: '2026-06-11T13:26:47.561184Z', + lastLoginTime: '2026-07-16T03:12:03.033412Z', + }; + + it('accepts a full camelCase domain payload', () => { + expect(managedUserInfoResultSchema.parse({ kind: 'ok', userInfo: fullUserInfo })).toEqual({ + kind: 'ok', + userInfo: fullUserInfo, + }); + expect( + managedUserInfoResultSchema.parse({ kind: 'error', message: 'nope', status: 401 }), + ).toEqual({ kind: 'error', message: 'nope', status: 401 }); + }); + + it('rejects a payload missing a required field', () => { + expect(() => + managedUserInfoSchema.parse({ + userId: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + userLevelName: 'Vivace', + domain: 1, + domainName: 'DOMAIN_EXAMPLE', + }), + ).toThrow(); + }); + + it('strips unknown keys by default', () => { + const parsed = managedUserInfoSchema.parse({ + ...fullUserInfo, + unexpected_key: 'drop me', + }); + expect(parsed).not.toHaveProperty('unexpected_key'); + expect(parsed).toEqual(fullUserInfo); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66d2dcc3a0..6276af9425 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -989,6 +989,9 @@ importers: proper-lockfile: specifier: ^4.1.2 version: 4.1.2 + zod: + specifier: 'catalog:' + version: 4.3.6 devDependencies: '@types/proper-lockfile': specifier: ^4.1.4 @@ -13688,7 +13691,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: From b00072d2a282405d60623920566abcd0576acfa7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 29 Jul 2026 19:18:37 +0800 Subject: [PATCH 3/5] style(agent-core-v2): drop the method-local comment on getManagedUserInfo --- packages/agent-core-v2/src/app/auth/authService.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 209f99876f..ced1cde8a0 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -281,9 +281,6 @@ export class OAuthService extends Disposable implements IOAuthService { } getManagedUserInfo(provider = KIMI_CODE_PROVIDER_NAME): Promise { - // Same resolution path as the managed usage fetch: env-aware base url + - // oauth ref, so a self-hosted/proxied login environment reports its own - // profile endpoint. The toolkit handles token freshness and error mapping. const configured = this.providerService.get(provider); const auth = resolveKimiCodeRuntimeAuth({ configuredBaseUrl: configured?.baseUrl, From 9b9188febc7f9ad70e6067c56f4d27530f876491 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 14:02:43 +0800 Subject: [PATCH 4/5] chore: drop the userinfo endpoint changeset --- .changeset/managed-userinfo-endpoint.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/managed-userinfo-endpoint.md diff --git a/.changeset/managed-userinfo-endpoint.md b/.changeset/managed-userinfo-endpoint.md deleted file mode 100644 index 474a41ccbd..0000000000 --- a/.changeset/managed-userinfo-endpoint.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Add an account profile endpoint to the server REST API so the web UI can show the signed-in user's profile. From 28029bb5166a7718c1fa3d807fd9a87a246a9cec Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 30 Jul 2026 14:13:58 +0800 Subject: [PATCH 5/5] test(kap-server): pass the required host identity in the userinfo route test --- packages/kap-server/test/oauthUsage.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kap-server/test/oauthUsage.test.ts b/packages/kap-server/test/oauthUsage.test.ts index dbf506b1c1..fba3c1a50a 100644 --- a/packages/kap-server/test/oauthUsage.test.ts +++ b/packages/kap-server/test/oauthUsage.test.ts @@ -200,6 +200,7 @@ describe('server-v2 GET /api/v1/oauth/userinfo', () => { async function boot(seeds: ScopeSeed): Promise { server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home,