| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- /** Maintenance CLI. Connection and directory secrets stay in private files. */
- import fs from 'node:fs';
- import path from 'node:path';
- import crypto from 'node:crypto';
- import { fileURLToPath } from 'node:url';
- const sha = (value) => crypto.createHash('sha256').update(value).digest('hex');
- const fail = (code) => { throw Object.assign(new Error(code), { code }); };
- const root = path.dirname(fileURLToPath(import.meta.url));
- function privateJson(file) {
- if (!path.isAbsolute(file)) fail('ABSOLUTE_PRIVATE_CONFIG_REQUIRED');
- for (let current = file; current !== path.dirname(current); current = path.dirname(current)) if (fs.lstatSync(current).isSymbolicLink()) fail('UNSAFE_PRIVATE_CONFIG');
- const stat = fs.statSync(file);
- if (!stat.isFile() || stat.size > 1024 * 1024 || process.platform !== 'win32' && (stat.mode & 0o077)) fail('UNSAFE_PRIVATE_CONFIG');
- return JSON.parse(fs.readFileSync(file, 'utf8'));
- }
- export async function manage({ args, request, invoke, releaseDir = path.join(root, args[0] === 'deploy-employee-read' ? 'read-dist' : args[0] === 'deploy-employee-name' ? 'name-dist' : 'dist') }) {
- const command = args[0], apply = args.includes('--apply');
- const flags = { deploy: ['--apply', '--receipt-dir'], 'deploy-employee-name': ['--apply', '--receipt-dir'], 'deploy-employee-read': ['--apply', '--receipt-dir'], configure: ['--apply', '--file', '--receipt-dir'], sync: ['--apply', '--sessions', '--request-id'], rollback: ['--apply', '--receipt'] }[command];
- if (!flags) fail('USAGE');
- const seen = new Set();
- for (let i = 1; i < args.length; i++) { const flag = args[i]; if (!flags.includes(flag) || seen.has(flag)) fail('USAGE'); seen.add(flag); if (flag !== '--apply') { i++; if (!args[i] || args[i].startsWith('--')) fail('USAGE'); } }
- const value = (flag) => { const index = args.indexOf(flag); if (index < 0) return null; if (!args[index + 1] || args[index + 1].startsWith('--')) fail('USAGE'); return args[index + 1]; };
- const scan = async (name) => (await request('GET', '/classes/Function?' + new URLSearchParams({ where: JSON.stringify({ name }), limit: '2' }))).results;
- if (command === 'sync') {
- let ids = value('--sessions')?.split(',').filter(Boolean); const requestId = value('--request-id');
- if (ids?.length === 1 && ids[0] === 'all') {
- ids = []; let cursor = null;
- for (let page = 0; page < 100; page++) {
- const read = await invoke('ksfGetPublicBoard', { limit: 500, ...(cursor ? { cursor } : {}) });
- if (read?.result?.ok !== true) fail('PUBLIC_SESSIONS_UNAVAILABLE');
- ids.push(...read.result.data.session.filter((s) => s.employee).map((s) => s.objectId));
- cursor = read.result.data.nextCursor; if (!cursor) break;
- }
- if (cursor) fail('PUBLIC_SESSIONS_TRUNCATED');
- }
- if (!ids?.length || apply && !requestId) fail('USAGE');
- const response = await invoke('ksfSyncWecomEmployee', { sessionIds: ids, apply, ...(requestId ? { requestId } : {}) });
- if (!response?.result?.ok) fail(response?.result?.errors?.[0]?.code || 'EMPLOYEE_SYNC_FAILED');
- return response.result.data;
- }
- if (command === 'configure') {
- const cfg = privateJson(value('--file') || '');
- if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg) || Object.keys(cfg).some((k) => !['enabled', 'corpId', 'secret', 'organizationName', 'bots'].includes(k)) || cfg.enabled !== true || typeof cfg.corpId !== 'string' || !cfg.corpId.trim() || typeof cfg.secret !== 'string' || !cfg.secret.trim() || !cfg.bots || typeof cfg.bots !== 'object' || Array.isArray(cfg.bots)) fail('INVALID_DIRECTORY_CONFIG');
- for (const bot of Object.values(cfg.bots)) {
- if (bot.mappingVerified !== true || !['mapped', 'directory_userid'].includes(bot.senderIdType) || bot.senderIdType === 'mapped' && (!bot.userIds || typeof bot.userIds !== 'object')) fail('INVALID_DIRECTORY_MAPPING');
- }
- const [runtime] = (await request('GET', '/classes/KsfRuntimeConfig?' + new URLSearchParams({ where: JSON.stringify({ key: 'runtime' }), limit: '2' }))).results || [];
- if (!runtime?.value) fail('RUNTIME_NOT_FOUND');
- let backup = null;
- if (apply) {
- const receiptDir = value('--receipt-dir'); if (!receiptDir || !path.isAbsolute(receiptDir)) fail('RECEIPT_DIRECTORY_REQUIRED');
- fs.mkdirSync(receiptDir, { recursive: true, mode: 0o700 });
- backup = path.join(receiptDir, 'directory-config-before-' + crypto.randomUUID() + '.json');
- fs.writeFileSync(backup, JSON.stringify(runtime, null, 2), { flag: 'wx', mode: 0o600 });
- const current = await request('GET', '/classes/KsfRuntimeConfig/' + runtime.objectId);
- if (sha(JSON.stringify(current.value)) !== sha(JSON.stringify(runtime.value))) fail('CONCURRENT_CONFIG_CHANGE');
- await request('PUT', '/classes/KsfRuntimeConfig/' + runtime.objectId, { value: { ...runtime.value, wecomDirectory: cfg } });
- const checked = await request('GET', '/classes/KsfRuntimeConfig/' + runtime.objectId);
- if (JSON.stringify(checked.value.wecomDirectory) !== JSON.stringify(cfg)) fail('CONFIG_READBACK_MISMATCH');
- }
- return { ok: true, applied: apply, configuredBots: Object.keys(cfg.bots).length, directorySecretPrinted: false, backup };
- }
- if (command === 'rollback') {
- const receiptFile = value('--receipt'); if (!receiptFile) fail('USAGE');
- const receipt = privateJson(receiptFile);
- const nameRelease = receipt.version === 'ksf.employee-name.20261103.v1';
- const readRelease = receipt.version === 'ksf.wecom-employee-read.20261103.v1';
- if (!Array.isArray(receipt.entries) || (nameRelease ? receipt.entries.length !== 2 || !receipt.entries.some(e => e.name === 'ksfGetPublicBoard') || !receipt.entries.some(e => e.name === 'ksfEmployeeDisplayName') : readRelease ? receipt.entries.length !== 1 || receipt.entries[0].name !== 'ksfReadWecomEmployee' : receipt.version !== 'ksf.wecom-identity.20261103.v1' || ![2, 4].includes(receipt.entries.length))) fail('INVALID_RECEIPT');
- const restore = [];
- if (new Set(receipt.entries.map((e) => e.name)).size !== receipt.entries.length) fail('INVALID_RECEIPT');
- for (const entry of receipt.entries) {
- if (!(nameRelease ? ['ksfGetPublicBoard', 'ksfEmployeeDisplayName'] : readRelease ? ['ksfReadWecomEmployee'] : ['ksfGetPublicBoard', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask']).includes(entry.name)) fail('INVALID_RECEIPT');
- if (!entry.id) { if ((await scan(entry.name)).length) fail('UNKNOWN_CREATED_FUNCTION'); continue; }
- if (!/^[\w-]+$/.test(entry.id)) fail('INVALID_RECEIPT');
- const row = await request('GET', '/classes/Function/' + entry.id);
- if (entry.prior && sha(entry.prior.code) !== entry.priorSha256) fail('BACKUP_MISMATCH');
- if (row.name !== entry.name) fail('CONCURRENT_FUNCTION_CHANGE');
- if (entry.prior && sha(row.code) === entry.priorSha256) continue;
- if (sha(row.code) !== entry.afterSha256) fail('CONCURRENT_FUNCTION_CHANGE');
- restore.push(entry);
- }
- if (apply) for (const entry of restore.reverse()) {
- const row = await request('GET', '/classes/Function/' + entry.id); if (sha(row.code) !== entry.afterSha256) fail('CONCURRENT_FUNCTION_CHANGE');
- if (entry.prior) { await request('PUT', '/classes/Function/' + entry.id, { code: entry.prior.code }); if (sha((await request('GET', '/classes/Function/' + entry.id)).code) !== entry.priorSha256) fail('ROLLBACK_READBACK_MISMATCH'); }
- else await request('DELETE', '/classes/Function/' + entry.id);
- }
- return { ok: true, operation: 'rollback', applied: apply };
- }
- if (!['deploy', 'deploy-employee-read', 'deploy-employee-name'].includes(command)) fail('USAGE');
- const manifest = JSON.parse(fs.readFileSync(path.join(releaseDir, 'manifest.json'), 'utf8'));
- const readRelease = command === 'deploy-employee-read', nameRelease = command === 'deploy-employee-name';
- if (manifest.version !== (nameRelease ? 'ksf.employee-name.20261103.v1' : readRelease ? 'ksf.wecom-employee-read.20261103.v1' : 'ksf.wecom-identity.20261103.v1')) fail('BAD_RELEASE');
- if (nameRelease && (manifest.functions?.length !== 2 || manifest.functions[0].name !== 'ksfGetPublicBoard' || !manifest.functions[0].priorSha256 || manifest.functions[1].name !== 'ksfEmployeeDisplayName' || manifest.functions[1].priorSha256 !== null)) fail('BAD_RELEASE');
- let sourceBase;
- if (readRelease) {
- if (manifest.functions?.length !== 1 || manifest.functions[0].name !== 'ksfReadWecomEmployee' || manifest.functions[0].priorSha256 !== null || manifest.sourceBase?.name !== 'ksfGetPublicBoard') fail('BAD_RELEASE');
- const sources = await scan(manifest.sourceBase.name); [sourceBase] = sources;
- if (sources.length !== 1 || sourceBase.objectId !== manifest.sourceBase.objectId || sha(sourceBase.code) !== manifest.sourceBase.sha256) fail('FUNCTION_BASELINE_MISMATCH');
- }
- const planned = [];
- for (const entry of manifest.functions) {
- const code = fs.readFileSync(path.join(releaseDir, entry.name + '.js'), 'utf8');
- if (sha(code) !== entry.sha256) fail('RELEASE_HASH_MISMATCH');
- const matches = await scan(entry.name); if (matches.length > 1) fail('DUPLICATE_FUNCTION');
- const prior = matches[0];
- if (prior && sha(prior.code) === entry.sha256) { planned.push({ ...entry, prior, code, unchanged: true }); continue; }
- if (entry.priorSha256 ? !prior || prior.objectId !== entry.objectId || sha(prior.code) !== entry.priorSha256 : prior) fail('FUNCTION_BASELINE_MISMATCH');
- planned.push({ ...entry, prior, code });
- }
- if (!apply) return { ok: true, readOnly: true, functions: planned.map((p) => ({ name: p.name, unchanged: !!p.unchanged, sha256: p.sha256 })) };
- if (planned.every((p) => p.unchanged)) return { ok: true, unchanged: true };
- // Refuse a mixed partial install; preserve its receipt for an explicit recovery.
- if (planned.some((p) => p.unchanged)) fail('PARTIAL_INSTALL_REQUIRES_REVIEW');
- if (readRelease) {
- const current = await request('GET', '/classes/Function/' + sourceBase.objectId);
- if (current.name !== sourceBase.name || sha(current.code) !== manifest.sourceBase.sha256 || JSON.stringify(current.ACL) !== JSON.stringify(sourceBase.ACL)) fail('CONCURRENT_FUNCTION_CHANGE');
- }
- const receiptDir = value('--receipt-dir'); if (!receiptDir || !path.isAbsolute(receiptDir)) fail('RECEIPT_DIRECTORY_REQUIRED');
- fs.mkdirSync(receiptDir, { recursive: true, mode: 0o700 });
- const receiptFile = path.join(receiptDir, 'identity-deploy-' + crypto.randomUUID() + '.json');
- const receipt = { version: manifest.version, status: 'prepared', entries: planned.map((p) => ({ name: p.name, id: p.prior?.objectId || null, prior: p.prior || null, priorSha256: p.priorSha256, afterSha256: p.sha256 })) };
- const save = () => fs.writeFileSync(receiptFile, JSON.stringify(receipt, null, 2), { mode: 0o600 }); save();
- // Public projection is installed last; preserve the journal if any earlier function fails.
- for (const entry of [...planned].reverse()) {
- const current = await scan(entry.name);
- if (entry.prior ? current.length !== 1 || sha(current[0].code) !== entry.priorSha256 : current.length !== 0) fail('CONCURRENT_FUNCTION_CHANGE');
- let id = entry.prior?.objectId;
- if (id) await request('PUT', '/classes/Function/' + id, { code: entry.code });
- else id = (await request('POST', '/classes/Function', { name: entry.name, path: entry.path, code: entry.code, ACL: (sourceBase || planned[0].prior).ACL })).objectId;
- receipt.entries.find((r) => r.name === entry.name).id = id; save();
- const read = await request('GET', '/classes/Function/' + id); if (sha(read.code) !== entry.sha256) fail('FUNCTION_READBACK_MISMATCH');
- }
- receipt.status = 'verified'; save();
- return { ok: true, applied: true, receipt: receiptFile, functions: planned.length, businessRecordsChanged: 0 };
- }
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
- try {
- const cfg = privateJson(process.env.KSF_PLATFORM_CONFIG || '');
- if (cfg.appId !== 'FKieRbIoGt_main' || !cfg.masterKey) fail('INVALID_PLATFORM_CONFIG');
- for (const key of ['parseUrl', 'functionsUrl']) if (new URL(cfg[key]).origin !== 'https://kangshifu.fmode.cn') fail('WRONG_TARGET');
- const headers = { 'X-Parse-Application-Id': cfg.appId, 'X-Parse-Master-Key': cfg.masterKey, 'Content-Type': 'application/json' };
- const call = async (url, method, body) => {
- let response;
- try { response = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(30000) }); } catch { fail('PLATFORM_UNAVAILABLE'); }
- if (!response.ok) fail('PLATFORM_HTTP_ERROR');
- try { return await response.json(); } catch { fail('PLATFORM_INVALID_RESPONSE'); }
- };
- const request = (method, route, body) => call(cfg.parseUrl.replace(/\/$/, '') + route, method, body);
- const invoke = (name, params) => call(cfg.functionsUrl, 'POST', { path: '/' + name, params });
- console.log(JSON.stringify(await manage({ args: process.argv.slice(2), request, invoke }), null, 2));
- } catch (e) { console.error(JSON.stringify({ ok: false, code: /^[A-Z][A-Z0-9_]+$/.test(e.code || '') ? e.code : 'IDENTITY_MAINTENANCE_STOPPED', message: '已停止;保留私有回执核对,不输出凭据或接口原文。' })); process.exitCode = 1; }
- }
|