Skip to content

Commit cb253c5

Browse files
committed
feat(connector): support feishu qr bind flow in hub
- add Feishu QR generate/poll backend routes with env persistence - add Feishu QR panel in IM Hub and wire status refresh - improve save-config hint when QR binding already wrote sensitive env - add API/Web tests for Feishu QR and connector save UX - ignore .claude directory
1 parent e828cd0 commit cb253c5

7 files changed

Lines changed: 784 additions & 3 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ packages/web/public/worker-*.js
3232
.kiro/
3333
.trae/
3434
.dare/
35+
.claude/
3536
.codex/
3637
.gemini/
3738
*.swp

packages/api/src/routes/connector-hub.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2+
import { resolve } from 'node:path';
13
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
24
import { DEFAULT_THREAD_ID, type IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js';
35
import type { WeixinAdapter } from '../infrastructure/connectors/adapters/WeixinAdapter.js';
46
import type { IConnectorPermissionStore } from '../infrastructure/connectors/ConnectorPermissionStore.js';
7+
import { resolveActiveProjectRoot } from '../utils/active-project-root.js';
58
import { resolveHeaderUserId } from '../utils/request-identity.js';
69

710
export interface ConnectorHubRoutesOptions {
@@ -16,6 +19,10 @@ export interface ConnectorHubRoutesOptions {
1619
startWeixinPolling?: () => void;
1720
/** F134 Phase D: Permission store for group whitelist + admin management */
1821
permissionStore?: IConnectorPermissionStore | null;
22+
/** Optional override for writing connector env updates in tests */
23+
envFilePath?: string;
24+
/** Optional fetch override for Feishu registration API in tests */
25+
feishuRegistrationFetch?: typeof fetch;
1926
}
2027

2128
function requireTrustedHubIdentity(request: FastifyRequest, reply: FastifyReply): string | null {
@@ -57,6 +64,11 @@ interface PlatformDef {
5764
steps: PlatformStepDef[];
5865
}
5966

67+
const FEISHU_ACCOUNTS_BASE_URL = 'https://accounts.feishu.cn';
68+
const LARK_ACCOUNTS_BASE_URL = 'https://accounts.larksuite.com';
69+
70+
type FeishuRegistrationResponse = Record<string, unknown>;
71+
6072
export const CONNECTOR_PLATFORMS: PlatformDef[] = [
6173
{
6274
id: 'feishu',
@@ -169,6 +181,86 @@ function maskSensitiveValue(_value: string): string {
169181
return '••••••••';
170182
}
171183

184+
function formatEnvFileValue(value: string): string {
185+
const escapedControlChars = value.replace(/\r/g, '\\r').replace(/\n/g, '\\n');
186+
if (/^[A-Za-z0-9_./:@-]+$/.test(escapedControlChars)) return escapedControlChars;
187+
return `"${escapedControlChars
188+
.replace(/\\/g, '\\\\')
189+
.replace(/"/g, '\\"')
190+
.replace(/\$/g, '\\$')
191+
.replace(/`/g, '\\`')}"`;
192+
}
193+
194+
function applyEnvUpdatesToFile(contents: string, updates: Map<string, string | null>): string {
195+
const lines = contents === '' ? [] : contents.split(/\r?\n/);
196+
const seen = new Set<string>();
197+
const nextLines: string[] = [];
198+
199+
for (const line of lines) {
200+
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
201+
if (!match) {
202+
nextLines.push(line);
203+
continue;
204+
}
205+
const name = match[1]!;
206+
if (!updates.has(name)) {
207+
nextLines.push(line);
208+
continue;
209+
}
210+
seen.add(name);
211+
const value = updates.get(name);
212+
if (value == null || value === '') continue;
213+
nextLines.push(`${name}=${formatEnvFileValue(value)}`);
214+
}
215+
216+
for (const [name, value] of updates) {
217+
if (seen.has(name) || value == null || value === '') continue;
218+
nextLines.push(`${name}=${formatEnvFileValue(value)}`);
219+
}
220+
221+
const normalized = nextLines
222+
.join('\n')
223+
.replace(/\n{3,}/g, '\n\n')
224+
.trimEnd();
225+
return normalized.length > 0 ? `${normalized}\n` : '';
226+
}
227+
228+
function persistEnvUpdates(envFilePath: string, updates: Map<string, string | null>): void {
229+
const current = existsSync(envFilePath) ? readFileSync(envFilePath, 'utf8') : '';
230+
const next = applyEnvUpdatesToFile(current, updates);
231+
writeFileSync(envFilePath, next, 'utf8');
232+
for (const [name, value] of updates) {
233+
if (value == null || value === '') delete process.env[name];
234+
else process.env[name] = value;
235+
}
236+
}
237+
238+
async function postFeishuRegistration(
239+
fetchFn: typeof fetch,
240+
baseUrl: string,
241+
form: URLSearchParams,
242+
): Promise<FeishuRegistrationResponse> {
243+
const res = await fetchFn(`${baseUrl}/oauth/v1/app/registration`, {
244+
method: 'POST',
245+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
246+
body: form.toString(),
247+
});
248+
const data = (await res.json().catch(() => ({}))) as FeishuRegistrationResponse;
249+
if (!res.ok && !('error' in data)) {
250+
throw new Error(`registration api ${res.status}`);
251+
}
252+
return data;
253+
}
254+
255+
function toPositiveNumber(value: unknown, fallback: number): number {
256+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
257+
if (typeof value === 'string') {
258+
const parsed = Number(value);
259+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
260+
}
261+
return fallback;
262+
}
263+
172264
export interface PlatformFieldStatus {
173265
envName: string;
174266
label: string;
@@ -237,6 +329,8 @@ export function buildConnectorStatus(env: Record<string, string | undefined> = p
237329

238330
export const connectorHubRoutes: FastifyPluginAsync<ConnectorHubRoutesOptions> = async (app, opts) => {
239331
const { threadStore } = opts;
332+
const envFilePath = opts.envFilePath ?? resolve(resolveActiveProjectRoot(), '.env');
333+
const feishuRegistrationFetch = opts.feishuRegistrationFetch ?? globalThis.fetch;
240334

241335
app.get('/api/connector/hub-threads', async (request, reply) => {
242336
const userId = requireTrustedHubIdentity(request, reply);
@@ -274,6 +368,127 @@ export const connectorHubRoutes: FastifyPluginAsync<ConnectorHubRoutesOptions> =
274368
return { platforms: status };
275369
});
276370

371+
// ── Feishu QR code create/bind routes ──
372+
373+
app.post('/api/connector/feishu/qrcode', async (request, reply) => {
374+
const userId = requireTrustedHubIdentity(request, reply);
375+
if (!userId) return { error: 'Identity required' };
376+
377+
try {
378+
const initData = await postFeishuRegistration(
379+
feishuRegistrationFetch,
380+
FEISHU_ACCOUNTS_BASE_URL,
381+
new URLSearchParams({ action: 'init' }),
382+
);
383+
const supportedMethods = Array.isArray(initData.supported_auth_methods) ? initData.supported_auth_methods : [];
384+
if (!supportedMethods.includes('client_secret')) {
385+
reply.status(502);
386+
return { error: 'Feishu registration endpoint does not support client_secret auth method' };
387+
}
388+
389+
const beginData = await postFeishuRegistration(
390+
feishuRegistrationFetch,
391+
FEISHU_ACCOUNTS_BASE_URL,
392+
new URLSearchParams({
393+
action: 'begin',
394+
archetype: 'PersonalAgent',
395+
auth_method: 'client_secret',
396+
request_user_info: 'open_id',
397+
}),
398+
);
399+
400+
const verificationUri = beginData.verification_uri_complete;
401+
const deviceCode = beginData.device_code;
402+
if (typeof verificationUri !== 'string' || typeof deviceCode !== 'string') {
403+
reply.status(502);
404+
return { error: 'Feishu registration response is missing QR payload' };
405+
}
406+
407+
const qrUrl = new URL(verificationUri);
408+
qrUrl.searchParams.set('from', 'onboard');
409+
410+
const QRCode = await import('qrcode');
411+
const qrDataUri = await QRCode.toDataURL(qrUrl.toString(), { width: 384, margin: 2 });
412+
413+
return {
414+
qrUrl: qrDataUri,
415+
qrPayload: deviceCode,
416+
interval: toPositiveNumber(beginData.interval, 5),
417+
expiresIn: toPositiveNumber(beginData.expire_in, 600),
418+
};
419+
} catch (err) {
420+
app.log.error({ err }, '[Feishu QR] Failed to fetch QR code');
421+
reply.status(502);
422+
return { error: 'Failed to fetch QR code from Feishu registration service' };
423+
}
424+
});
425+
426+
app.get('/api/connector/feishu/qrcode-status', async (request, reply) => {
427+
const userId = requireTrustedHubIdentity(request, reply);
428+
if (!userId) return { error: 'Identity required' };
429+
430+
const { qrPayload } = request.query as { qrPayload?: string };
431+
if (!qrPayload) {
432+
reply.status(400);
433+
return { error: 'qrPayload query parameter required' };
434+
}
435+
436+
try {
437+
const pollForm = new URLSearchParams({ action: 'poll', device_code: qrPayload });
438+
let pollData = await postFeishuRegistration(feishuRegistrationFetch, FEISHU_ACCOUNTS_BASE_URL, pollForm);
439+
440+
const tenantBrand = ((pollData.user_info as Record<string, unknown> | undefined)?.tenant_brand ?? '') as string;
441+
const hasCredentials = typeof pollData.client_id === 'string' && typeof pollData.client_secret === 'string';
442+
if (!hasCredentials && tenantBrand === 'lark') {
443+
try {
444+
pollData = await postFeishuRegistration(feishuRegistrationFetch, LARK_ACCOUNTS_BASE_URL, pollForm);
445+
} catch (err) {
446+
app.log.warn({ err }, '[Feishu QR] Lark poll fallback failed');
447+
}
448+
}
449+
450+
const clientId = pollData.client_id;
451+
const clientSecret = pollData.client_secret;
452+
if (typeof clientId === 'string' && typeof clientSecret === 'string') {
453+
const updates = new Map<string, string | null>([
454+
['FEISHU_APP_ID', clientId],
455+
['FEISHU_APP_SECRET', clientSecret],
456+
]);
457+
const currentMode = process.env.FEISHU_CONNECTION_MODE === 'websocket' ? 'websocket' : 'webhook';
458+
const verificationToken = process.env.FEISHU_VERIFICATION_TOKEN;
459+
if (currentMode === 'webhook' && (!verificationToken || verificationToken.trim() === '')) {
460+
// QR onboarding does not return webhook verification token; default to websocket so setup is immediately valid.
461+
updates.set('FEISHU_CONNECTION_MODE', 'websocket');
462+
}
463+
persistEnvUpdates(envFilePath, updates);
464+
app.log.info('[Feishu QR] Bot credentials captured and persisted to env file');
465+
return { status: 'confirmed' };
466+
}
467+
468+
const errorCode = pollData.error;
469+
if (errorCode === 'authorization_pending' || errorCode === 'slow_down') {
470+
return { status: 'waiting' };
471+
}
472+
if (errorCode === 'access_denied') {
473+
return { status: 'denied' };
474+
}
475+
if (errorCode === 'expired_token') {
476+
return { status: 'expired' };
477+
}
478+
if (typeof errorCode === 'string') {
479+
return {
480+
status: 'error',
481+
error: typeof pollData.error_description === 'string' ? pollData.error_description : errorCode,
482+
};
483+
}
484+
return { status: 'waiting' };
485+
} catch (err) {
486+
app.log.error({ err }, '[Feishu QR] Failed to poll QR status');
487+
reply.status(502);
488+
return { error: 'Failed to poll Feishu QR status' };
489+
}
490+
});
491+
277492
// ── F137: WeChat QR code login routes ──
278493

279494
app.post('/api/connector/weixin/qrcode', async (request, reply) => {

packages/api/test/connector-hub-route.test.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import assert from 'node:assert/strict';
2+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
25
import { describe, it } from 'node:test';
36
import Fastify from 'fastify';
47

58
const { connectorHubRoutes } = await import('../dist/routes/connector-hub.js');
69

710
const AUTH_HEADERS = { 'x-cat-cafe-user': 'owner-1' };
811

12+
function jsonResponse(body, status = 200) {
13+
return new Response(JSON.stringify(body), {
14+
status,
15+
headers: { 'content-type': 'application/json' },
16+
});
17+
}
18+
919
async function buildApp(overrides = {}) {
1020
const listCalls = [];
1121
const threadStore = {
@@ -194,6 +204,110 @@ describe('POST /api/connector/weixin/disconnect', () => {
194204
});
195205
});
196206

207+
describe('Feishu QR routes', () => {
208+
it('POST /api/connector/feishu/qrcode returns QR image and payload', async () => {
209+
const app = Fastify();
210+
await app.register(connectorHubRoutes, {
211+
threadStore: {
212+
async list() {
213+
return [];
214+
},
215+
},
216+
feishuRegistrationFetch: async (_url, init) => {
217+
const form = new URLSearchParams(String(init?.body ?? ''));
218+
const action = form.get('action');
219+
if (action === 'init') {
220+
return jsonResponse({ supported_auth_methods: ['client_secret'] });
221+
}
222+
if (action === 'begin') {
223+
return jsonResponse({
224+
verification_uri_complete: 'https://accounts.feishu.cn/oauth/verify?token=abc',
225+
device_code: 'device-abc',
226+
interval: 5,
227+
expire_in: 600,
228+
});
229+
}
230+
return jsonResponse({ error: 'unexpected_action' }, 400);
231+
},
232+
});
233+
await app.ready();
234+
235+
const res = await app.inject({
236+
method: 'POST',
237+
url: '/api/connector/feishu/qrcode',
238+
headers: AUTH_HEADERS,
239+
});
240+
241+
assert.equal(res.statusCode, 200);
242+
const body = JSON.parse(res.body);
243+
assert.equal(body.qrPayload, 'device-abc');
244+
assert.ok(typeof body.qrUrl === 'string' && body.qrUrl.startsWith('data:image/png;base64,'));
245+
assert.equal(body.interval, 5);
246+
assert.equal(body.expiresIn, 600);
247+
248+
await app.close();
249+
});
250+
251+
it('GET /api/connector/feishu/qrcode-status persists credentials to env file on confirm', async () => {
252+
const tempRoot = mkdtempSync(join(tmpdir(), 'cat-cafe-feishu-qr-'));
253+
const envFilePath = join(tempRoot, '.env');
254+
writeFileSync(envFilePath, '', 'utf8');
255+
256+
const originalEnv = {
257+
FEISHU_APP_ID: process.env.FEISHU_APP_ID,
258+
FEISHU_APP_SECRET: process.env.FEISHU_APP_SECRET,
259+
FEISHU_CONNECTION_MODE: process.env.FEISHU_CONNECTION_MODE,
260+
};
261+
262+
const app = Fastify();
263+
await app.register(connectorHubRoutes, {
264+
threadStore: {
265+
async list() {
266+
return [];
267+
},
268+
},
269+
envFilePath,
270+
feishuRegistrationFetch: async (_url, init) => {
271+
const form = new URLSearchParams(String(init?.body ?? ''));
272+
const action = form.get('action');
273+
if (action === 'poll') {
274+
return jsonResponse({
275+
client_id: 'cli_test_app_id',
276+
client_secret: 'test_app_secret_123',
277+
user_info: { open_id: 'ou_test', tenant_brand: 'feishu' },
278+
});
279+
}
280+
return jsonResponse({ error: 'unexpected_action' }, 400);
281+
},
282+
});
283+
await app.ready();
284+
285+
try {
286+
const res = await app.inject({
287+
method: 'GET',
288+
url: '/api/connector/feishu/qrcode-status?qrPayload=device-xyz',
289+
headers: AUTH_HEADERS,
290+
});
291+
assert.equal(res.statusCode, 200);
292+
assert.equal(JSON.parse(res.body).status, 'confirmed');
293+
294+
const envText = readFileSync(envFilePath, 'utf8');
295+
assert.match(envText, /FEISHU_APP_ID=cli_test_app_id/);
296+
assert.match(envText, /FEISHU_APP_SECRET=test_app_secret_123/);
297+
assert.match(envText, /FEISHU_CONNECTION_MODE=websocket/);
298+
} finally {
299+
if (originalEnv.FEISHU_APP_ID == null) delete process.env.FEISHU_APP_ID;
300+
else process.env.FEISHU_APP_ID = originalEnv.FEISHU_APP_ID;
301+
if (originalEnv.FEISHU_APP_SECRET == null) delete process.env.FEISHU_APP_SECRET;
302+
else process.env.FEISHU_APP_SECRET = originalEnv.FEISHU_APP_SECRET;
303+
if (originalEnv.FEISHU_CONNECTION_MODE == null) delete process.env.FEISHU_CONNECTION_MODE;
304+
else process.env.FEISHU_CONNECTION_MODE = originalEnv.FEISHU_CONNECTION_MODE;
305+
await app.close();
306+
rmSync(tempRoot, { recursive: true, force: true });
307+
}
308+
});
309+
});
310+
197311
describe('GET /api/connector/hub-threads', () => {
198312
it('returns 401 when only a spoofed userId query param is provided', async () => {
199313
const { app } = await buildApp();

0 commit comments

Comments
 (0)