${escapeHtml(profile.name)}
${escapeHtml(providerDetail)}
From 6b93391a9b106d5aafad2cab50b0739bdc4fcdb9 Mon Sep 17 00:00:00 2001
From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com>
Date: Wed, 29 Jul 2026 17:20:31 +0200
Subject: [PATCH] feat: add transactional deploy key lifecycle
---
SOURCE_MANIFEST.txt | 49 ++----
main.cjs | 9 +
preload.cjs | 6 +
src/main/deploy-key-lifecycle-service.cjs | 191 ++++++++++++++++++++++
src/main/gitea-service.cjs | 13 ++
src/main/ipc.cjs | 28 ++++
src/main/unraid-deploy-key-host.cjs | 76 +++++++++
src/renderer/app.js | 66 +++++++-
src/renderer/mock-bridge.js | 25 +++
tests/deploy-key-lifecycle.test.mjs | 139 ++++++++++++++++
10 files changed, 563 insertions(+), 39 deletions(-)
create mode 100644 src/main/deploy-key-lifecycle-service.cjs
create mode 100644 src/main/unraid-deploy-key-host.cjs
create mode 100644 tests/deploy-key-lifecycle.test.mjs
diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt
index 3429e9f..ff981e1 100644
--- a/SOURCE_MANIFEST.txt
+++ b/SOURCE_MANIFEST.txt
@@ -15,38 +15,8 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
-8e84a93929c6f359f090d67c16c7eea4a1c123e8170d3bd9b269f047f8560215 284299 coverage/tmp/coverage-10244-1785335090178-0.json
-8e0457e916f249b7a3389894df3f2b664ad8797db1c792e0c107f57ca9f56bec 303088 coverage/tmp/coverage-13124-1785335091173-0.json
-f219872995f4c34d4d6628c6ac7859ee51f3441cc03272ab1653da7acf7959bd 354433 coverage/tmp/coverage-18204-1785335089962-0.json
-95bee92580b38e4488d5ec5653b0d92e674ad8fa537cee9705143f079cc4993d 317634 coverage/tmp/coverage-19144-1785335101870-0.json
-50158ef242c1b19100949f7beb8e40f81f84fed97c6816db672a7c2a6e897016 329357 coverage/tmp/coverage-23492-1785335091006-0.json
-93a3e9159d736053e543cff989779d91f1d21e10f0ff6d3e554e867a54513ee3 342639 coverage/tmp/coverage-25088-1785335089991-0.json
-fa262d693cb615960736e290858dddedee49a1e7db5f863d8649fcbcbf10afbd 335944 coverage/tmp/coverage-3052-1785335090008-0.json
-07850b9d5fce7c6b271cbdef84af7e079f1034a4630699f0c584694228951ae6 282471 coverage/tmp/coverage-34880-1785335091385-0.json
-a11df85ec00b29553b6d396f36fc567db2982a619321e0fdd33ec2a628da4fec 298998 coverage/tmp/coverage-36452-1785335089751-0.json
-9b7a2ad01bca25678f57d254f93cd894cc34ec2243aef93cb93905dd7ce41a9f 301397 coverage/tmp/coverage-36468-1785335089356-0.json
-70aafc483e2158fb120049e59655a4aed7836d25ad91e66dfe8c50016670ee6f 344612 coverage/tmp/coverage-39296-1785335095999-0.json
-28278f6e18e9bab0e5c684596591b4cbf814ac477510d8796d036be678086dbb 288319 coverage/tmp/coverage-39956-1785335090649-0.json
-ec355623c897784f803bbcf4531ead16c863283d9e58f8d423781706b5300c20 304507 coverage/tmp/coverage-42012-1785335089647-0.json
-1d06d8d2a2f2a6ede0c6912dcd8c9fada4955f52b1dbf85d330b950c4feead89 318325 coverage/tmp/coverage-42356-1785335091476-0.json
-4dce288c8da452d2b12cfed7de6efd0f36ebb64ba0262220b964bd991e427cd5 352005 coverage/tmp/coverage-43752-1785335089626-0.json
-21ed865ff22e016b6da1f4d1886b6f87fd1e4a471c6606d6bcb748c6bdd30b75 111307 coverage/tmp/coverage-44028-1785335091684-0.json
-f05901535653256b5a05b8a8f8aa6b5019a54c8d099adbe1d702713a8f00d12c 89174 coverage/tmp/coverage-47072-1785335088858-0.json
-d2a8f1f5fff4f7b090b182b6f7666ef06d08b1b0be497b0f7061e5a5234145d0 360056 coverage/tmp/coverage-47440-1785335147487-0.json
-3d4927400e2212ce74d9026ab678d9766ce16917d3830bc4e5f47cb48fae6381 344933 coverage/tmp/coverage-48456-1785335092567-0.json
-5987881f09402e371a5998b8f5208e41156e4d9a270a4d74aa70512faba406ab 302345 coverage/tmp/coverage-52268-1785335090577-0.json
-4d1dde6f8cca29f40b2dd018cdefc9ef2201941323ad53a0af892c6bd4afebc9 345120 coverage/tmp/coverage-58112-1785335104676-0.json
-32aa3248a14053e28632d42490d0a4d3b082fa8406e37c35645a9a7cd6e3d628 302512 coverage/tmp/coverage-58124-1785335090307-0.json
-ee85e5c654ded256066430543d4fea9394db545e4a92054bad731f65cec7dc73 310268 coverage/tmp/coverage-58672-1785335091776-0.json
-b2b99cf55dc44ce6fdb87f9bd62425d72231dc0f04558d0706a2ac7619a4a505 321056 coverage/tmp/coverage-58732-1785335091791-0.json
-33c28c4ef5b3c3c89616ff6b6f4f1ce00aca3169c93315f428a69ca62207cfb8 357939 coverage/tmp/coverage-59060-1785335093199-0.json
-d2ecb7c43ded5e946f3dbc1067809d5ddb12d74f359c2e62756d243e2cfdc33f 421305 coverage/tmp/coverage-59124-1785335092134-0.json
-49af6e452b0f192565453e6a8dffc979948a9cbbeb7453e1ce325cfc47f751b3 296834 coverage/tmp/coverage-59200-1785335090383-0.json
-c4b99d78def3b2fe1223e80f3a6648bec128dc89b5b45c1d308d435241e51f21 360489 coverage/tmp/coverage-59612-1785335091346-0.json
-72501c88d2e3699ca478f031db0ac31859d39b66db96f66fd8887feda6158a11 367435 coverage/tmp/coverage-59824-1785335091587-0.json
-43a7b9b9f701313bbe345c3e7baaa51525d25675fa2cad1a14d72b6a16676730 344498 coverage/tmp/coverage-59992-1785335090598-0.json
-24ff83322c312d1a71d3446994011d107daf1da2c06e66c2819ad8782c02f633 335634 coverage/tmp/coverage-60172-1785335098629-0.json
-405c0992d25d0779ae42ff7728e5471d737ea446f8c11b6af911927b6be48d11 327419 coverage/tmp/coverage-60332-1785335088943-0.json
+2889007aef2cba3e324cda97892a7292ccf9b194e38b2a41c851f65a8a91bc17 89174 coverage/tmp/coverage-22080-1785338379849-0.json
+cd725a6cfaacbe4580c61193a8772674279692b9632b4f41612c7eb4e349a57c 345759 coverage/tmp/coverage-40380-1785338384553-0.json
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
72e846f591c47a0291e7466e58e052d3d5afcf551c4e6c848632ac3c552a1244 3043 docs/CURRENT_STATE.md
@@ -116,11 +86,11 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
-3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs
+ca5cebd83df4db0c5b77ded9914eef09e4d027b920e524d631fece8ecfcba6d9 13468 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
f3e2a262e251eb165f342dc3c184625ec10d56e536b5a987199b9944f463eee9 181069 package-lock.json
48e25abda43f5463c5c4631f5a29f7e9b2e744383a23489b4e54dedf38c74940 4817 package.json
-82865e814a82628b778c53ec83bcda26bf1109d8bb14c11208530089f4dc96db 10522 preload.cjs
+d1924fa08d1f33762bb965faca8dfb71d7c9de23927a4e33be663de18945d849 11316 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
@@ -142,13 +112,14 @@ d2dd98055e50f11b4e1484531e43fb5ac7f876bea4b9cf5bca2cb0a15022b60a 1913
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
80e0afcfa77dbc617c425cd603e5c165ea425b5f47046538657bc1dc51d7b1e7 28670 src/main/config-store.cjs
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
+86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs
-d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs
-a8c677d5ee1343f6ff230e8a30c2ecee9f47c56c89518e34f25755d6c0c3b685 51410 src/main/ipc.cjs
+2faaef0eeec1e473db82b94243674e3812ef5869358b0ec6abf8d645a8794623 20768 src/main/gitea-service.cjs
+6b95d18288aa3becacaaaa21ee7f827af87e977755c0e9d68132441fa72f38cc 53023 src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
@@ -156,15 +127,16 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
+2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs
b90285d4bece4705554b2dae72f3db61a614b87f57b28a03b1e94d55f1ad4ad0 149903 src/main/unraid-deployment-service.cjs
b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs
-953b027fedcee2c2396ec5c8cf938013f2630d8ef76ab5e917a02d723303234f 241018 src/renderer/app.js
+71cc25ec300737a8a0072602ae3c484ab2e670ef0c88d26b0777717de119512d 247577 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html
-ae8d6778614077c5f46032035a10fb9803219c14e8bfabefacf1affdec06fb71 62802 src/renderer/mock-bridge.js
+5c6e8e0080043fc320c689c59c9611fa23b895b2fab7783866e8357dd3d1b1fa 65633 src/renderer/mock-bridge.js
45692591428575b518678a6b548c25d3de95f568541e5648b0f06f42c48b5bbf 77872 src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
@@ -181,6 +153,7 @@ f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
+b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
diff --git a/main.cjs b/main.cjs
index 855cd32..150951f 100644
--- a/main.cjs
+++ b/main.cjs
@@ -25,6 +25,8 @@ const {
UnraidDeploymentService,
} = require("./src/main/unraid-deployment-service.cjs");
const { AuditService } = require("./src/main/audit-service.cjs");
+const { DeployKeyLifecycleService } = require("./src/main/deploy-key-lifecycle-service.cjs");
+const { UnraidDeployKeyHost } = require("./src/main/unraid-deploy-key-host.cjs");
const { GitValidatorService } = require("./src/main/git-validator-service.cjs");
const {
ExternalToolsService,
@@ -264,6 +266,12 @@ app
sourcePath: app.getAppPath(),
onOperationChange: reportOperationChange,
});
+ const deployKeys = new DeployKeyLifecycleService({
+ store,
+ gitea,
+ keyHost: new UnraidDeployKeyHost({ ssh }),
+ audit,
+ });
const updates = new UpdateService({
store,
gitea,
@@ -305,6 +313,7 @@ app
repositories,
deployments,
unraid,
+ deployKeys,
ssh,
updates,
preflight,
diff --git a/preload.cjs b/preload.cjs
index db35698..3ffd95b 100644
--- a/preload.cjs
+++ b/preload.cjs
@@ -126,6 +126,12 @@ contextBridge.exposeInMainWorld(
linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }),
configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }),
verifyServerGitProfile: (repository, profileId) => invoke('deployment:verify-server-git-profile', { repository, profileId }),
+ deployKeyInventory: (repository, profileId) => invoke('deployment:deploy-key-inventory', { repository, profileId }),
+ planDeployKeyRotation: (repository, profileId) => invoke('deployment:plan-deploy-key-rotation', { repository, profileId }),
+ applyDeployKeyRotation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-rotation', { repository, profileId, planId }),
+ planDeployKeyRevocation: (repository, profileId) => invoke('deployment:plan-deploy-key-revocation', { repository, profileId }),
+ applyDeployKeyRevocation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-revocation', { repository, profileId, planId }),
+ restoreDeployKey: (repository, profileId) => invoke('deployment:restore-deploy-key', { repository, profileId }),
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
diff --git a/src/main/deploy-key-lifecycle-service.cjs b/src/main/deploy-key-lifecycle-service.cjs
new file mode 100644
index 0000000..7ac3aef
--- /dev/null
+++ b/src/main/deploy-key-lifecycle-service.cjs
@@ -0,0 +1,191 @@
+"use strict";
+
+const crypto = require("node:crypto");
+
+function stable(value) {
+ if (Array.isArray(value)) return value.map(stable);
+ if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
+ return value;
+}
+
+function planId(value) {
+ return crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex");
+}
+
+function keyMaterial(value) {
+ return String(value || "").trim().split(/\s+/).slice(0, 2).join(" ");
+}
+
+class DeployKeyLifecycleService {
+ constructor({ store, gitea, keyHost, audit = null, clock = () => new Date().toISOString() }) {
+ this.store = store;
+ this.gitea = gitea;
+ this.keyHost = keyHost;
+ this.audit = audit;
+ this.clock = clock;
+ }
+
+ coordinates(repository) {
+ const [owner, repo] = String(repository?.fullName || "").split("/");
+ if (!owner || !repo) throw Object.assign(new Error("A full Gitea repository name is required."), { code: "DEPLOY_KEY_REPOSITORY_REQUIRED" });
+ return { owner, repo };
+ }
+
+ profile(repository, profileId) {
+ const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
+ if (!profile) throw Object.assign(new Error("Deployment profile not found."), { code: "DEPLOY_KEY_PROFILE_NOT_FOUND" });
+ const server = this.store.getServer(profile.serverId);
+ if (!server) throw Object.assign(new Error("Deployment server not found."), { code: "DEPLOY_KEY_SERVER_NOT_FOUND" });
+ return { profile, server };
+ }
+
+ configuredReferences() {
+ const references = [];
+ const configured = this.store.data?.deploymentProfiles
+ ? Object.entries(this.store.data.deploymentProfiles).map(([fullName, profiles]) => ({ fullName, profiles }))
+ : (this.store.getRepositories?.() || []).map((repository) => ({ fullName: repository.fullName, profiles: this.store.getDeploymentProfiles(repository.fullName) || [] }));
+ for (const repository of configured) {
+ for (const profile of repository.profiles || []) {
+ if (!profile.serverGitAccess?.deployKeyId && !profile.serverGitAccess?.keyFingerprint) continue;
+ references.push({ repository: repository.fullName, profileId: profile.id, serverId: profile.serverId, keyId: profile.serverGitAccess.deployKeyId || null, fingerprint: profile.serverGitAccess.keyFingerprint || null });
+ }
+ }
+ return references;
+ }
+
+ async inventory({ repository, profileId }) {
+ const { profile, server } = this.profile(repository, profileId);
+ const { owner, repo } = this.coordinates(repository);
+ const [remoteKeys, serverKey] = await Promise.all([
+ this.gitea.listDeployKeys(owner, repo),
+ this.keyHost.inspect({ repository, profile, server }),
+ ]);
+ const configuredId = Number(profile.serverGitAccess?.deployKeyId) || null;
+ const configured = remoteKeys.find((key) => Number(key.id) === configuredId) || null;
+ const material = keyMaterial(serverKey?.publicKey);
+ const matching = material ? remoteKeys.filter((key) => keyMaterial(key.key) === material) : [];
+ const references = this.configuredReferences();
+ const shared = references.filter((reference) => reference.fingerprint && reference.fingerprint === serverKey?.fingerprint && (reference.repository !== repository.fullName || reference.profileId !== profileId));
+ const conflicts = remoteKeys.filter((key) => key.read_only !== true && (!configuredId || Number(key.id) === configuredId || keyMaterial(key.key) === material));
+ const stale = Boolean(configuredId && !configured) || Boolean(profile.serverGitAccess?.keyFingerprint && serverKey?.fingerprint && profile.serverGitAccess.keyFingerprint !== serverKey.fingerprint);
+ const orphaned = remoteKeys.filter((key) => /ForgeFlow/i.test(String(key.title || "")) && !references.some((reference) => Number(reference.keyId) === Number(key.id)));
+ return {
+ repository: repository.fullName, profileId, server: { id: server.id, name: server.name },
+ configuredKey: configured ? { id: configured.id, title: configured.title, readOnly: configured.read_only === true, key: configured.key || null } : null,
+ serverKey, matchingKeys: matching.map((key) => ({ id: key.id, readOnly: key.read_only === true })),
+ stale, orphaned: orphaned.map((key) => ({ id: key.id, title: key.title })), shared, conflicts: conflicts.map((key) => ({ id: key.id, title: key.title, readOnly: false })),
+ ready: Boolean(configured && configured.read_only === true && serverKey?.privateKeyPresent && serverKey?.fingerprint === profile.serverGitAccess?.keyFingerprint && !shared.length && !conflicts.length),
+ checkedAt: this.clock(),
+ };
+ }
+
+ async planRotation({ repository, profileId }) {
+ const evidence = await this.inventory({ repository, profileId });
+ const plan = {
+ operation: "rotate-deploy-key", repository: repository.fullName, profileId,
+ currentKeyId: evidence.configuredKey?.id || null, currentFingerprint: evidence.serverKey?.fingerprint || null,
+ serverId: evidence.server.id, impact: ["Generate a new private key on the linked server", "Register only its public key in this repository", "Verify read-only branch access", "Switch the profile atomically", "Revoke the previous key after the switch"],
+ recovery: "The previous server key and profile metadata remain recoverable until post-rotation verification succeeds.", evidence,
+ };
+ plan.id = planId(plan);
+ await this.audit?.append?.("deployment.deploy-key-rotation-planned", { repository: repository.fullName, profileId, planId: plan.id });
+ return plan;
+ }
+
+ async rotate({ repository, profileId, expectedPlanId }) {
+ const plan = await this.planRotation({ repository, profileId });
+ if (!expectedPlanId) throw Object.assign(new Error("Review a deploy-key rotation plan before applying it."), { code: "DEPLOY_KEY_ROTATION_PLAN_REQUIRED", plan });
+ if (expectedPlanId !== plan.id) throw Object.assign(new Error("Deploy-key evidence changed after preview. Review a fresh plan."), { code: "DEPLOY_KEY_ROTATION_PLAN_STALE", plan });
+ const { profile, server } = this.profile(repository, profileId);
+ const { owner, repo } = this.coordinates(repository);
+ const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-rotation:${repository.fullName}:${profileId}`);
+ const previous = { profile: structuredClone(profile), key: await this.keyHost.backup({ repository, profile, server }), remoteKey: plan.evidence.configuredKey };
+ let candidate = null;
+ let registered = null;
+ let switched = false;
+ let oldRevoked = false;
+ try {
+ candidate = await this.keyHost.generate({ repository, profile, server });
+ if (!candidate?.publicKey || !candidate?.fingerprint || candidate.privateKey) throw Object.assign(new Error("The server did not return safe public-key evidence."), { code: "DEPLOY_KEY_CANDIDATE_INVALID" });
+ registered = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · ${candidate.fingerprint.slice(-12)}`, publicKey: candidate.publicKey });
+ if (registered.read_only !== true) throw Object.assign(new Error("Gitea registered the candidate with write access."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
+ const proof = await this.keyHost.verifyCandidate({ repository, profile, server, candidate, keyId: registered.id });
+ if (!proof?.ready || proof.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("The candidate deploy key could not prove read-only repository access."), { code: "DEPLOY_KEY_CANDIDATE_VERIFICATION_FAILED", proof });
+ await this.keyHost.preflightCandidate({ repository, profile, server, candidate, proof });
+ await this.keyHost.promote({ repository, profile, server, candidate, previous });
+ const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { configured: true, deployKeyId: registered.id, keyFingerprint: candidate.fingerprint, hostFingerprint: proof.hostFingerprint, configuredAt: this.clock(), rotatedAt: this.clock(), previousKeyId: previous.remoteKey?.id || null } });
+ switched = true;
+ if (previous.remoteKey?.id) {
+ await this.gitea.deleteDeployKey(owner, repo, previous.remoteKey.id);
+ oldRevoked = true;
+ }
+ const post = await this.keyHost.verifyActive({ repository, profile: updated, server });
+ if (!post?.ready || post.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("Post-rotation verification failed."), { code: "DEPLOY_KEY_POST_ROTATION_FAILED", post });
+ await this.keyHost.commit({ repository, profile: updated, server, candidate, previous });
+ await this.audit?.append?.("deployment.deploy-key-rotated", { repository: repository.fullName, profileId, oldKeyId: previous.remoteKey?.id || null, newKeyId: registered.id, fingerprint: candidate.fingerprint, snapshot: snapshot?.filePath || null });
+ return { profile: updated, proof: post, snapshot, recovery: previous.key?.recovery || null };
+ } catch (error) {
+ try {
+ if (candidate) await this.keyHost.rollback({ repository, profile, server, candidate, previous });
+ if (registered?.id) await this.gitea.deleteDeployKey(owner, repo, registered.id).catch(() => {});
+ let restoredKey = null;
+ if (oldRevoked && previous.remoteKey?.key) restoredKey = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: previous.remoteKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: previous.remoteKey.key });
+ if (switched) await this.store.saveDeploymentProfile(repository.fullName, restoredKey ? { ...previous.profile, serverGitAccess: { ...previous.profile.serverGitAccess, deployKeyId: restoredKey.id } } : previous.profile);
+ } catch (rollbackError) {
+ error.rollbackError = rollbackError.message;
+ }
+ await this.audit?.append?.("deployment.deploy-key-rotation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_ROTATION_FAILED", rollbackError: error.rollbackError || null });
+ throw error;
+ }
+ }
+
+ async planRevocation({ repository, profileId }) {
+ const evidence = await this.inventory({ repository, profileId });
+ const plan = { operation: "revoke-deploy-key", repository: repository.fullName, profileId, keyId: evidence.configuredKey?.id || null, fingerprint: evidence.serverKey?.fingerprint || null, linkedDeployments: [profileId], impact: ["Remove this repository deploy key from Gitea", "Disable server-pull deployment until restored", "Preserve server-side recovery material"], containersUnaffected: true, evidence };
+ plan.id = planId(plan);
+ return plan;
+ }
+
+ async revoke({ repository, profileId, expectedPlanId }) {
+ const plan = await this.planRevocation({ repository, profileId });
+ if (!expectedPlanId) throw Object.assign(new Error("Review revocation impact before applying it."), { code: "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED", plan });
+ if (plan.id !== expectedPlanId) throw Object.assign(new Error("Deploy-key evidence changed after preview."), { code: "DEPLOY_KEY_REVOCATION_PLAN_STALE", plan });
+ const { profile, server } = this.profile(repository, profileId);
+ const { owner, repo } = this.coordinates(repository);
+ const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-revocation:${repository.fullName}:${profileId}`);
+ const recovery = await this.keyHost.backup({ repository, profile, server });
+ let remoteDeleted = false;
+ try {
+ if (plan.keyId) { await this.gitea.deleteDeployKey(owner, repo, plan.keyId); remoteDeleted = true; }
+ await this.keyHost.revoke({ repository, profile, server, recovery });
+ const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, configured: false, revokedAt: this.clock(), recoveryAvailable: true }, deploymentMode: "monitor-only" });
+ await this.audit?.append?.("deployment.deploy-key-revoked", { repository: repository.fullName, profileId, keyId: plan.keyId, snapshot: snapshot?.filePath || null });
+ return { profile: updated, snapshot, recovery: recovery?.recovery || null };
+ } catch (error) {
+ if (remoteDeleted && plan.evidence.configuredKey?.key) {
+ const restored = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: plan.evidence.configuredKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: plan.evidence.configuredKey.key });
+ await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, deployKeyId: restored.id } });
+ }
+ await this.keyHost.restore({ repository, profile, server }).catch(() => {});
+ await this.audit?.append?.("deployment.deploy-key-revocation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_REVOCATION_FAILED" });
+ throw error;
+ }
+ }
+
+ async restore({ repository, profileId }) {
+ const { profile, server } = this.profile(repository, profileId);
+ const restored = await this.keyHost.restore({ repository, profile, server });
+ if (!restored?.publicKey || !restored?.fingerprint) throw Object.assign(new Error("No valid deploy-key recovery material exists."), { code: "DEPLOY_KEY_RECOVERY_UNAVAILABLE" });
+ const { owner, repo } = this.coordinates(repository);
+ const key = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · restored`, publicKey: restored.publicKey });
+ if (key.read_only !== true) throw Object.assign(new Error("The restored key is not read-only."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
+ const proposed = { ...profile, deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: key.id, keyFingerprint: restored.fingerprint, hostFingerprint: restored.hostFingerprint, restoredAt: this.clock() } };
+ const proof = await this.keyHost.verifyActive({ repository, profile: proposed, server });
+ if (!proof?.ready) throw Object.assign(new Error("Restored access could not be verified."), { code: "DEPLOY_KEY_RECOVERY_VERIFICATION_FAILED" });
+ const updated = await this.store.saveDeploymentProfile(repository.fullName, proposed);
+ await this.audit?.append?.("deployment.deploy-key-restored", { repository: repository.fullName, profileId, keyId: key.id, fingerprint: restored.fingerprint });
+ return { profile: updated, proof };
+ }
+}
+
+module.exports = { DeployKeyLifecycleService, keyMaterial, deployKeyPlanId: planId };
diff --git a/src/main/gitea-service.cjs b/src/main/gitea-service.cjs
index 022c552..81a1313 100644
--- a/src/main/gitea-service.cjs
+++ b/src/main/gitea-service.cjs
@@ -264,6 +264,19 @@ class GiteaService {
return { ...result.data, created: true };
}
+ async createReadOnlyDeployKey({ owner, repo, title, publicKey }) {
+ const key = String(publicKey || "").trim();
+ if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) throw new Error("A valid SSH public key is required.");
+ const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, { method: "POST", body: { title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), key, read_only: true } });
+ return result.data;
+ }
+
+ async deleteDeployKey(owner, repo, keyId) {
+ if (!Number.isInteger(Number(keyId)) || Number(keyId) <= 0) throw new Error("A valid deploy-key ID is required.");
+ await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys/${Number(keyId)}`, { method: "DELETE" });
+ return { deleted: true, keyId: Number(keyId) };
+ }
+
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({
state,
diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs
index 1d9e2d0..d1592e4 100644
--- a/src/main/ipc.cjs
+++ b/src/main/ipc.cjs
@@ -84,6 +84,7 @@ function registerIpc({
repositories,
deployments,
unraid,
+ deployKeys,
ssh,
updates,
preflight,
@@ -1226,6 +1227,33 @@ function registerIpc({
});
return result;
});
+ register("deployment:deploy-key-inventory", async ({ repository, profileId }) => {
+ const current = await resolveRepository(repository);
+ return deployKeys.inventory({ repository: current, profileId });
+ });
+ register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => {
+ const current = await resolveRepository(repository);
+ return deployKeys.planRotation({ repository: current, profileId });
+ });
+ register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => {
+ const current = await resolveRepository(repository);
+ const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId });
+ return { ...result, state: store.getPublicState() };
+ });
+ register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => {
+ const current = await resolveRepository(repository);
+ return deployKeys.planRevocation({ repository: current, profileId });
+ });
+ register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => {
+ const current = await resolveRepository(repository);
+ const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId });
+ return { ...result, state: store.getPublicState() };
+ });
+ register("deployment:restore-deploy-key", async ({ repository, profileId }) => {
+ const current = await resolveRepository(repository);
+ const result = await deployKeys.restore({ repository: current, profileId });
+ return { ...result, state: store.getPublicState() };
+ });
register("deployment:discover-server-workloads", async () => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter(
diff --git a/src/main/unraid-deploy-key-host.cjs b/src/main/unraid-deploy-key-host.cjs
new file mode 100644
index 0000000..948e501
--- /dev/null
+++ b/src/main/unraid-deploy-key-host.cjs
@@ -0,0 +1,76 @@
+"use strict";
+
+const crypto = require("node:crypto");
+const path = require("node:path").posix;
+const { shellQuote } = require("./ssh-service.cjs");
+
+const bash = (command) => `printf '%s' ${shellQuote(Buffer.from(`set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n${command}`, "utf8").toString("base64"))} | base64 -d | bash`;
+function parseMarker(stdout, marker) {
+ const text = String(stdout || "");
+ const index = text.lastIndexOf(marker);
+ if (index < 0) throw new Error(`Server key operation did not return ${marker}.`);
+ return Object.fromEntries(text.slice(index + marker.length).trim().split(/\r?\n/).map((line) => { const separator = line.indexOf("="); return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; }));
+}
+
+class UnraidDeployKeyHost {
+ constructor({ ssh }) { this.ssh = ssh; }
+ paths(repository, server) {
+ const id = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
+ const directory = path.join(server.basePath, ".forgeflow", "git-credentials", id);
+ return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") };
+ }
+ remote(repository, profile) {
+ const value = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
+ if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
+ return value;
+ }
+ environment(paths) { return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${paths.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${paths.knownHosts}`)}`; }
+ async execute(server, script, options = {}) { return this.ssh.exec(server.id, bash(script), { timeout: options.timeout || 30_000, maxOutput: options.maxOutput || 128 * 1024 }); }
+ async inspect({ repository, server }) {
+ const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_INSPECT__";
+ const script = `printf '%s\\n' ${shellQuote(marker)}; printf 'privateKeyPresent=%s\\n' "$([ -s ${shellQuote(p.privateKey)} ] && echo true || echo false)"; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"; printf 'fingerprint=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}' || true)"; printf 'hostFingerprint=%s\\n' "$([ -s ${shellQuote(p.knownHosts)} ] && ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, - || true)"`;
+ const f = parseMarker((await this.execute(server, script)).stdout, marker);
+ return { privateKeyPresent: f.privateKeyPresent === "true", publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
+ }
+ async backup({ repository, server }) {
+ const p = this.paths(repository, server); const slot = path.join(p.recovery, `backup-${Date.now()}-${crypto.randomUUID()}`); const marker = "__FORGEFLOW_KEY_BACKUP__";
+ const script = `umask 077; mkdir -p ${shellQuote(slot)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || cp -p ${shellQuote(p.directory)}/"$name" ${shellQuote(slot)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'recovery=%s\\n' ${shellQuote(slot)}; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"`;
+ const f = parseMarker((await this.execute(server, script)).stdout, marker);
+ return { recovery: f.recovery, publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null };
+ }
+ async generate({ repository, server }) {
+ const active = this.paths(repository, server); const directory = path.join(active.directory, `candidate-${crypto.randomUUID()}`); const p = { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts") }; const marker = "__FORGEFLOW_KEY_CANDIDATE__";
+ const script = `umask 077; mkdir -p ${shellQuote(directory)}; ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow-rotation:${repository.fullName}`)} -f ${shellQuote(p.privateKey)}; cp -p ${shellQuote(active.knownHosts)} ${shellQuote(p.knownHosts)}; chmod 600 ${shellQuote(p.privateKey)} ${shellQuote(p.knownHosts)}; chmod 644 ${shellQuote(p.publicKey)}; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
+ const f = parseMarker((await this.execute(server, script)).stdout, marker);
+ return { paths: p, publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
+ }
+ async verifyCandidate({ repository, profile, server, candidate }) {
+ const marker = "__FORGEFLOW_KEY_PROOF__"; const remote = this.remote(repository, profile); const p = candidate.paths;
+ const script = `output="$(${this.environment(p)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})"; printf '%s\\n' ${shellQuote(marker)}; printf 'remoteSha=%s\\n' "$(printf '%s' "$output" | awk 'NR==1 {print $1}')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
+ const f = parseMarker((await this.execute(server, script, { timeout: 45_000, maxOutput: 256 * 1024 })).stdout, marker);
+ return { ready: /^[0-9a-f]{40}$/i.test(f.remoteSha || ""), remoteSha: f.remoteSha || null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
+ }
+ async preflightCandidate(context) { const proof = await this.verifyCandidate(context); if (!proof.ready) throw new Error("Candidate preflight did not prove the remote branch."); return proof; }
+ async promote({ repository, server, candidate }) {
+ const p = this.paths(repository, server); const c = candidate.paths;
+ await this.execute(server, `test -s ${shellQuote(c.privateKey)}; test -s ${shellQuote(c.publicKey)}; test -s ${shellQuote(c.knownHosts)}; cp -p ${shellQuote(c.privateKey)} ${shellQuote(p.privateKey)}.new; cp -p ${shellQuote(c.publicKey)} ${shellQuote(p.publicKey)}.new; cp -p ${shellQuote(c.knownHosts)} ${shellQuote(p.knownHosts)}.new; mv ${shellQuote(p.privateKey)}.new ${shellQuote(p.privateKey)}; mv ${shellQuote(p.publicKey)}.new ${shellQuote(p.publicKey)}; mv ${shellQuote(p.knownHosts)}.new ${shellQuote(p.knownHosts)}`);
+ }
+ async verifyActive({ repository, profile, server }) { const paths = this.paths(repository, server); return this.verifyCandidate({ repository, profile, server, candidate: { paths } }); }
+ async rollback({ repository, server, candidate, previous }) {
+ const p = this.paths(repository, server); const recovery = previous.key.recovery;
+ await this.execute(server, `for name in deploy-key deploy-key.pub known_hosts; do test ! -s ${shellQuote(recovery)}/"$name" || cp -p ${shellQuote(recovery)}/"$name" ${shellQuote(p.directory)}/"$name"; done; rm -rf -- ${shellQuote(candidate.paths.directory)}`);
+ }
+ async commit({ server, candidate }) { await this.execute(server, `rm -rf -- ${shellQuote(candidate.paths.directory)}`); }
+ async revoke({ repository, server }) {
+ const p = this.paths(repository, server); const revoked = path.join(p.recovery, `revoked-${Date.now()}-${crypto.randomUUID()}`);
+ await this.execute(server, `umask 077; mkdir -p ${shellQuote(revoked)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || mv ${shellQuote(p.directory)}/"$name" ${shellQuote(revoked)}/"$name"; done`);
+ }
+ async restore({ repository, server }) {
+ const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_RESTORE__";
+ const script = `slot="$(find ${shellQuote(p.recovery)} -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort | tail -1)"; test -n "$slot"; for name in deploy-key deploy-key.pub known_hosts; do test -s "$slot/$name"; cp -p "$slot/$name" ${shellQuote(p.directory)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
+ const f = parseMarker((await this.execute(server, script)).stdout, marker);
+ return { publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
+ }
+}
+
+module.exports = { UnraidDeployKeyHost, parseDeployKeyMarker: parseMarker };
diff --git a/src/renderer/app.js b/src/renderer/app.js
index 96b04c8..1926e17 100644
--- a/src/renderer/app.js
+++ b/src/renderer/app.js
@@ -157,6 +157,7 @@ const ui = {
systemPreflight: null,
deploymentPreflight: null,
serverGitVerifications: {},
+ deployKeyLifecycle: null,
diagnosticsStatus: null,
troubleshooter: null,
deploymentDiscovery: null,
@@ -1019,7 +1020,7 @@ function renderProfileCard(repository, profile, compact = false) {
? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD"
: state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare";
const serverAccessAction = isSsh && mode === "server-git"
- ? ``
+ ? ``
: "";
return ` ${escapeHtml(providerDetail)}${escapeHtml(profile.name)}
${escapeHtml(rotation?.recovery || "")}
Plan ${escapeHtml(rotation?.id || "unavailable")}Containers remain untouched. Server pull changes to monitoring-only until restored.
Plan ${escapeHtml(revocation?.id || "unavailable")}