import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; const sha = text => crypto.createHash('sha256').update(text).digest('hex'); const fail = code => { throw Object.assign(Error(code), { code }); }; const canonical = value => JSON.stringify(value && typeof value === 'object' ? Array.isArray(value) ? value.map(v => JSON.parse(canonical(v))) : Object.fromEntries(Object.keys(value).sort().map(k => [k, JSON.parse(canonical(value[k]))])) : value); /** Preserve the actual deployed function, including identity/account wrappers. Only its artifact whitelist changes. */ export function patchPublicBoardCode(code) { if (typeof code !== 'string' || !code.includes('ksfGetPublicBoard')) fail('UNRECOGNIZED_PUBLIC_BOARD'); const pattern = /(\bartifact\s*:\s*['"])(objectId\s+[^'"\r\n]+)(['"])/g; const matches = [...code.matchAll(pattern)]; if (matches.length !== 1) fail('PUBLIC_ARTIFACT_WHITELIST_AMBIGUOUS'); const fields = matches[0][2].split(/\s+/); if (!['rawText', 'report', 'ownerName', 'version', 'dataMode', 'dashboardState', 'workshopKey'].every(k => fields.includes(k))) fail('PUBLIC_ARTIFACT_CONTRACT_CHANGED'); if (fields.includes('reportSeriesKey')) return code; const index = fields.indexOf('version');fields.splice(index + 1, 0, 'reportSeriesKey'); const next = code.replace(pattern, (_all, prefix, _fields, suffix) => prefix + fields.join(' ') + suffix); new Function(next); return next; } export async function deployBoardSeries({ request, manifest = null, apply = false, backupDir }) { const rows = (await request('GET', '/classes/Function?' + new URLSearchParams({ where: JSON.stringify({ name: 'ksfGetPublicBoard' }), limit: '2' }))).results; if (!Array.isArray(rows) || rows.length !== 1 || rows[0].name !== 'ksfGetPublicBoard' || rows[0].path !== '/ksfGetPublicBoard') fail('PUBLIC_BOARD_IDENTITY_MISMATCH'); const before = rows[0], next = patchPublicBoardCode(before.code); const plan = { schema: 'ksf.workshop-board-series.v1', functionName: before.name, objectId: before.objectId, beforeSha256: sha(before.code), afterSha256: sha(next), changed: next !== before.code, fieldAdded: 'reportSeriesKey', fullFunctionRebuild: false, businessRowsChanged: 0 }; if (!apply) return { ok: true, readOnly: true, ...plan }; if (!manifest || manifest.objectId !== before.objectId || manifest.afterSha256 !== plan.afterSha256 || ![manifest.beforeSha256, manifest.afterSha256].includes(plan.beforeSha256)) fail('DEPLOYMENT_BASELINE_CHANGED'); if (!plan.changed) return { ok: true, readOnly: true, ...plan, applied: false, alreadyDeployed: true }; if (!path.isAbsolute(backupDir || '')) fail('PRIVATE_BACKUP_REQUIRED'); const schemas = await request('GET', '/schemas'); fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); const backup = path.join(backupDir, 'public-board-before-' + crypto.randomUUID() + '.json'); fs.writeFileSync(backup, JSON.stringify(before), { flag: 'wx', mode: 0o600 }); const route = '/classes/Function/' + before.objectId; if (sha((await request('GET', route)).code) !== plan.beforeSha256) fail('CONCURRENT_FUNCTION_CHANGE'); let attempted = false; try { attempted = true;await request('PUT', route, { code: next }); const after = await request('GET', route); if (after.code !== next) fail('FUNCTION_READBACK_MISMATCH'); for (const key of ['name', 'path', 'ACL']) if (canonical(after[key] ?? null) !== canonical(before[key] ?? null)) fail('FUNCTION_IDENTITY_OR_ACL_CHANGED'); if (canonical(await request('GET', '/schemas')) !== canonical(schemas)) fail('SCHEMA_OR_PERMISSIONS_CHANGED'); return { ok: true, readOnly: false, ...plan, applied: true, backup, readbackVerified: true, schemaAndPermissionsPreserved: true, otherFunctionBodiesModified: false }; } catch (error) { let rollbackComplete = !attempted; if (attempted) try { const actual = await request('GET', route); if (actual.code === before.code) rollbackComplete = true; else if (actual.code === next) { await request('PUT', route, { code: before.code }); rollbackComplete = (await request('GET', route)).code === before.code; } } catch { rollbackComplete = false; } error.receipt = { ok: false, ...plan, backup, rollbackComplete, errorCode: error.code || 'DEPLOYMENT_UNCERTAIN' }; throw error; } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const args = process.argv.slice(2), apply = args.includes('--apply'); const root = path.resolve('work/ksf-workshop-board-20261110-r1'); const planPath = path.join(root, 'series-plan.json'); const { request } = await import('../server/scripts/platform-client.mjs'); const result = await deployBoardSeries({ request, apply, manifest: apply ? JSON.parse(fs.readFileSync(planPath, 'utf8')) : null, backupDir: path.join(root, 'private') }); fs.mkdirSync(root, { recursive: true }); fs.writeFileSync(apply ? path.join(root, 'series-deployment.json') : planPath, JSON.stringify(result, null, 2) + '\n'); console.log(JSON.stringify(result)); } catch (e) { console.error(JSON.stringify(e.receipt || { ok: false, errorCode: e.code || 'PUBLIC_BOARD_DEPLOYMENT_STOPPED' })); process.exitCode = 1; } }