manage.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. /** Maintenance CLI. Connection and directory secrets stay in private files. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import crypto from 'node:crypto';
  5. import { fileURLToPath } from 'node:url';
  6. const sha = (value) => crypto.createHash('sha256').update(value).digest('hex');
  7. const fail = (code) => { throw Object.assign(new Error(code), { code }); };
  8. const root = path.dirname(fileURLToPath(import.meta.url));
  9. function privateJson(file) {
  10. if (!path.isAbsolute(file)) fail('ABSOLUTE_PRIVATE_CONFIG_REQUIRED');
  11. for (let current = file; current !== path.dirname(current); current = path.dirname(current)) if (fs.lstatSync(current).isSymbolicLink()) fail('UNSAFE_PRIVATE_CONFIG');
  12. const stat = fs.statSync(file);
  13. if (!stat.isFile() || stat.size > 1024 * 1024 || process.platform !== 'win32' && (stat.mode & 0o077)) fail('UNSAFE_PRIVATE_CONFIG');
  14. return JSON.parse(fs.readFileSync(file, 'utf8'));
  15. }
  16. 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') }) {
  17. const command = args[0], apply = args.includes('--apply');
  18. 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];
  19. if (!flags) fail('USAGE');
  20. const seen = new Set();
  21. 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'); } }
  22. 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]; };
  23. const scan = async (name) => (await request('GET', '/classes/Function?' + new URLSearchParams({ where: JSON.stringify({ name }), limit: '2' }))).results;
  24. if (command === 'sync') {
  25. let ids = value('--sessions')?.split(',').filter(Boolean); const requestId = value('--request-id');
  26. if (ids?.length === 1 && ids[0] === 'all') {
  27. ids = []; let cursor = null;
  28. for (let page = 0; page < 100; page++) {
  29. const read = await invoke('ksfGetPublicBoard', { limit: 500, ...(cursor ? { cursor } : {}) });
  30. if (read?.result?.ok !== true) fail('PUBLIC_SESSIONS_UNAVAILABLE');
  31. ids.push(...read.result.data.session.filter((s) => s.employee).map((s) => s.objectId));
  32. cursor = read.result.data.nextCursor; if (!cursor) break;
  33. }
  34. if (cursor) fail('PUBLIC_SESSIONS_TRUNCATED');
  35. }
  36. if (!ids?.length || apply && !requestId) fail('USAGE');
  37. const response = await invoke('ksfSyncWecomEmployee', { sessionIds: ids, apply, ...(requestId ? { requestId } : {}) });
  38. if (!response?.result?.ok) fail(response?.result?.errors?.[0]?.code || 'EMPLOYEE_SYNC_FAILED');
  39. return response.result.data;
  40. }
  41. if (command === 'configure') {
  42. const cfg = privateJson(value('--file') || '');
  43. 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');
  44. for (const bot of Object.values(cfg.bots)) {
  45. if (bot.mappingVerified !== true || !['mapped', 'directory_userid'].includes(bot.senderIdType) || bot.senderIdType === 'mapped' && (!bot.userIds || typeof bot.userIds !== 'object')) fail('INVALID_DIRECTORY_MAPPING');
  46. }
  47. const [runtime] = (await request('GET', '/classes/KsfRuntimeConfig?' + new URLSearchParams({ where: JSON.stringify({ key: 'runtime' }), limit: '2' }))).results || [];
  48. if (!runtime?.value) fail('RUNTIME_NOT_FOUND');
  49. let backup = null;
  50. if (apply) {
  51. const receiptDir = value('--receipt-dir'); if (!receiptDir || !path.isAbsolute(receiptDir)) fail('RECEIPT_DIRECTORY_REQUIRED');
  52. fs.mkdirSync(receiptDir, { recursive: true, mode: 0o700 });
  53. backup = path.join(receiptDir, 'directory-config-before-' + crypto.randomUUID() + '.json');
  54. fs.writeFileSync(backup, JSON.stringify(runtime, null, 2), { flag: 'wx', mode: 0o600 });
  55. const current = await request('GET', '/classes/KsfRuntimeConfig/' + runtime.objectId);
  56. if (sha(JSON.stringify(current.value)) !== sha(JSON.stringify(runtime.value))) fail('CONCURRENT_CONFIG_CHANGE');
  57. await request('PUT', '/classes/KsfRuntimeConfig/' + runtime.objectId, { value: { ...runtime.value, wecomDirectory: cfg } });
  58. const checked = await request('GET', '/classes/KsfRuntimeConfig/' + runtime.objectId);
  59. if (JSON.stringify(checked.value.wecomDirectory) !== JSON.stringify(cfg)) fail('CONFIG_READBACK_MISMATCH');
  60. }
  61. return { ok: true, applied: apply, configuredBots: Object.keys(cfg.bots).length, directorySecretPrinted: false, backup };
  62. }
  63. if (command === 'rollback') {
  64. const receiptFile = value('--receipt'); if (!receiptFile) fail('USAGE');
  65. const receipt = privateJson(receiptFile);
  66. const nameRelease = receipt.version === 'ksf.employee-name.20261103.v1';
  67. const readRelease = receipt.version === 'ksf.wecom-employee-read.20261103.v1';
  68. 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');
  69. const restore = [];
  70. if (new Set(receipt.entries.map((e) => e.name)).size !== receipt.entries.length) fail('INVALID_RECEIPT');
  71. for (const entry of receipt.entries) {
  72. if (!(nameRelease ? ['ksfGetPublicBoard', 'ksfEmployeeDisplayName'] : readRelease ? ['ksfReadWecomEmployee'] : ['ksfGetPublicBoard', 'ksfSyncWecomEmployee', 'ksfStartWorkshopSession', 'ksfSaveWorkshopTask']).includes(entry.name)) fail('INVALID_RECEIPT');
  73. if (!entry.id) { if ((await scan(entry.name)).length) fail('UNKNOWN_CREATED_FUNCTION'); continue; }
  74. if (!/^[\w-]+$/.test(entry.id)) fail('INVALID_RECEIPT');
  75. const row = await request('GET', '/classes/Function/' + entry.id);
  76. if (entry.prior && sha(entry.prior.code) !== entry.priorSha256) fail('BACKUP_MISMATCH');
  77. if (row.name !== entry.name) fail('CONCURRENT_FUNCTION_CHANGE');
  78. if (entry.prior && sha(row.code) === entry.priorSha256) continue;
  79. if (sha(row.code) !== entry.afterSha256) fail('CONCURRENT_FUNCTION_CHANGE');
  80. restore.push(entry);
  81. }
  82. if (apply) for (const entry of restore.reverse()) {
  83. const row = await request('GET', '/classes/Function/' + entry.id); if (sha(row.code) !== entry.afterSha256) fail('CONCURRENT_FUNCTION_CHANGE');
  84. 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'); }
  85. else await request('DELETE', '/classes/Function/' + entry.id);
  86. }
  87. return { ok: true, operation: 'rollback', applied: apply };
  88. }
  89. if (!['deploy', 'deploy-employee-read', 'deploy-employee-name'].includes(command)) fail('USAGE');
  90. const manifest = JSON.parse(fs.readFileSync(path.join(releaseDir, 'manifest.json'), 'utf8'));
  91. const readRelease = command === 'deploy-employee-read', nameRelease = command === 'deploy-employee-name';
  92. if (manifest.version !== (nameRelease ? 'ksf.employee-name.20261103.v1' : readRelease ? 'ksf.wecom-employee-read.20261103.v1' : 'ksf.wecom-identity.20261103.v1')) fail('BAD_RELEASE');
  93. 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');
  94. let sourceBase;
  95. if (readRelease) {
  96. if (manifest.functions?.length !== 1 || manifest.functions[0].name !== 'ksfReadWecomEmployee' || manifest.functions[0].priorSha256 !== null || manifest.sourceBase?.name !== 'ksfGetPublicBoard') fail('BAD_RELEASE');
  97. const sources = await scan(manifest.sourceBase.name); [sourceBase] = sources;
  98. if (sources.length !== 1 || sourceBase.objectId !== manifest.sourceBase.objectId || sha(sourceBase.code) !== manifest.sourceBase.sha256) fail('FUNCTION_BASELINE_MISMATCH');
  99. }
  100. const planned = [];
  101. for (const entry of manifest.functions) {
  102. const code = fs.readFileSync(path.join(releaseDir, entry.name + '.js'), 'utf8');
  103. if (sha(code) !== entry.sha256) fail('RELEASE_HASH_MISMATCH');
  104. const matches = await scan(entry.name); if (matches.length > 1) fail('DUPLICATE_FUNCTION');
  105. const prior = matches[0];
  106. if (prior && sha(prior.code) === entry.sha256) { planned.push({ ...entry, prior, code, unchanged: true }); continue; }
  107. if (entry.priorSha256 ? !prior || prior.objectId !== entry.objectId || sha(prior.code) !== entry.priorSha256 : prior) fail('FUNCTION_BASELINE_MISMATCH');
  108. planned.push({ ...entry, prior, code });
  109. }
  110. if (!apply) return { ok: true, readOnly: true, functions: planned.map((p) => ({ name: p.name, unchanged: !!p.unchanged, sha256: p.sha256 })) };
  111. if (planned.every((p) => p.unchanged)) return { ok: true, unchanged: true };
  112. // Refuse a mixed partial install; preserve its receipt for an explicit recovery.
  113. if (planned.some((p) => p.unchanged)) fail('PARTIAL_INSTALL_REQUIRES_REVIEW');
  114. if (readRelease) {
  115. const current = await request('GET', '/classes/Function/' + sourceBase.objectId);
  116. if (current.name !== sourceBase.name || sha(current.code) !== manifest.sourceBase.sha256 || JSON.stringify(current.ACL) !== JSON.stringify(sourceBase.ACL)) fail('CONCURRENT_FUNCTION_CHANGE');
  117. }
  118. const receiptDir = value('--receipt-dir'); if (!receiptDir || !path.isAbsolute(receiptDir)) fail('RECEIPT_DIRECTORY_REQUIRED');
  119. fs.mkdirSync(receiptDir, { recursive: true, mode: 0o700 });
  120. const receiptFile = path.join(receiptDir, 'identity-deploy-' + crypto.randomUUID() + '.json');
  121. 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 })) };
  122. const save = () => fs.writeFileSync(receiptFile, JSON.stringify(receipt, null, 2), { mode: 0o600 }); save();
  123. // Public projection is installed last; preserve the journal if any earlier function fails.
  124. for (const entry of [...planned].reverse()) {
  125. const current = await scan(entry.name);
  126. if (entry.prior ? current.length !== 1 || sha(current[0].code) !== entry.priorSha256 : current.length !== 0) fail('CONCURRENT_FUNCTION_CHANGE');
  127. let id = entry.prior?.objectId;
  128. if (id) await request('PUT', '/classes/Function/' + id, { code: entry.code });
  129. else id = (await request('POST', '/classes/Function', { name: entry.name, path: entry.path, code: entry.code, ACL: (sourceBase || planned[0].prior).ACL })).objectId;
  130. receipt.entries.find((r) => r.name === entry.name).id = id; save();
  131. const read = await request('GET', '/classes/Function/' + id); if (sha(read.code) !== entry.sha256) fail('FUNCTION_READBACK_MISMATCH');
  132. }
  133. receipt.status = 'verified'; save();
  134. return { ok: true, applied: true, receipt: receiptFile, functions: planned.length, businessRecordsChanged: 0 };
  135. }
  136. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  137. try {
  138. const cfg = privateJson(process.env.KSF_PLATFORM_CONFIG || '');
  139. if (cfg.appId !== 'FKieRbIoGt_main' || !cfg.masterKey) fail('INVALID_PLATFORM_CONFIG');
  140. for (const key of ['parseUrl', 'functionsUrl']) if (new URL(cfg[key]).origin !== 'https://kangshifu.fmode.cn') fail('WRONG_TARGET');
  141. const headers = { 'X-Parse-Application-Id': cfg.appId, 'X-Parse-Master-Key': cfg.masterKey, 'Content-Type': 'application/json' };
  142. const call = async (url, method, body) => {
  143. let response;
  144. try { response = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(30000) }); } catch { fail('PLATFORM_UNAVAILABLE'); }
  145. if (!response.ok) fail('PLATFORM_HTTP_ERROR');
  146. try { return await response.json(); } catch { fail('PLATFORM_INVALID_RESPONSE'); }
  147. };
  148. const request = (method, route, body) => call(cfg.parseUrl.replace(/\/$/, '') + route, method, body);
  149. const invoke = (name, params) => call(cfg.functionsUrl, 'POST', { path: '/' + name, params });
  150. console.log(JSON.stringify(await manage({ args: process.argv.slice(2), request, invoke }), null, 2));
  151. } 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; }
  152. }