@attraccess/plugin-wago 0.1.0-nightly.941.1 → 0.1.0-nightly.943.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1414,7 +1414,7 @@ var WagoService = class {
1414
1414
  /** Server-side commissioning revokes the enrollment it created without exposing credentials to a browser. */
1415
1415
  async revokeEnrollmentById(id) {
1416
1416
  const enrollment = await this.enrollments.findOneBy({ id });
1417
- if (enrollment && this.isActiveEnrollment(enrollment)) await this.revokeEnrollment(enrollment);
1417
+ if (enrollment && !enrollment.consumedAt) await this.revokeEnrollment(enrollment);
1418
1418
  }
1419
1419
  async deleteEnrollmentById(id) {
1420
1420
  await this.enrollments.delete(id);
@@ -1486,6 +1486,34 @@ var WagoService = class {
1486
1486
  }
1487
1487
  });
1488
1488
  }
1489
+ /** Reverses the durable claim marker left behind when delivery is interrupted before completion is recorded. */
1490
+ async rollbackInterruptedClaim(hardwareId, mqttServerId, enrollmentId) {
1491
+ const controller = await this.controllers.findOneBy({ hardwareId });
1492
+ if (!controller || controller.trustState !== "claimed" || controller.mqttServerId !== mqttServerId || controller.enrollmentId !== enrollmentId)
1493
+ return;
1494
+ await this.withClaimLock(
1495
+ controller.id,
1496
+ () => this.withClaimConfigurationLock(async () => {
1497
+ const current = await this.controllers.findOneBy({ id: controller.id });
1498
+ if (!current || current.trustState !== "claimed" || current.mqttServerId !== mqttServerId || current.enrollmentId !== enrollmentId)
1499
+ return;
1500
+ const identity = `wago-controller-${current.hardwareId}`;
1501
+ const manual = await this.context.getMqttCredentialProvisioning().revoke({
1502
+ mqttServerId,
1503
+ identity,
1504
+ username: identity,
1505
+ vhost: "/"
1506
+ });
1507
+ if (manual)
1508
+ throw new import_common.ConflictException(`Manual credential revocation is required: ${manual.instructions.join(" ")}`);
1509
+ current.trustState = "untrusted";
1510
+ current.name = null;
1511
+ current.enrollmentId = null;
1512
+ current.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1513
+ await this.controllers.save(current);
1514
+ })
1515
+ );
1516
+ }
1489
1517
  async prepareClaim(id, name, verifier, mqttServerId) {
1490
1518
  const controller = await this.controllers.findOneBy({ id });
1491
1519
  if (!controller) throw new import_common.NotFoundException(`WAGO controller ${id} not found`);
@@ -2158,7 +2186,7 @@ var configuredRuntimeBundleSignature = process.env.WAGO_CC100_RUNTIME_BUNDLE_SIG
2158
2186
  var configuredRuntimeSigningPublicKey = process.env.WAGO_CC100_RUNTIME_SIGNING_PUBLIC_KEY_PATH?.trim() ?? "";
2159
2187
  var SIGNING_NAMESPACE = "attraccess-wago-runtime";
2160
2188
  var SIGNING_IDENTITY = "attraccess-wago-runtime";
2161
- var defaultSshCredential = { username: "root", password: "wago" };
2189
+ var VERIFIER_PREFIX = "encrypted:v1:";
2162
2190
  var WagoCommissioningService = class {
2163
2191
  constructor(context, wago) {
2164
2192
  __publicField(this, "context", context);
@@ -2166,15 +2194,16 @@ var WagoCommissioningService = class {
2166
2194
  __publicField(this, "sessions");
2167
2195
  __publicField(this, "deliveryLocks", /* @__PURE__ */ new Map());
2168
2196
  }
2169
- onApplicationBootstrap() {
2197
+ async onApplicationBootstrap() {
2170
2198
  this.sessions = this.context.getRepository(WagoCommissioningSession);
2171
- this.wago.registerCommissioningDiscoveryHandler((controller) => this.claimDiscovered(controller));
2172
- void this.resumePendingDeliveries().catch(
2173
- (error) => this.context.logger?.warn(`Could not resume pending WAGO commissioning deliveries: ${String(error)}`)
2174
- );
2175
- void this.reconcileCompletedSessions().catch(
2176
- (error) => this.context.logger?.warn(`Could not retire superseded WAGO commissioning sessions: ${String(error)}`)
2177
- );
2199
+ try {
2200
+ await this.recoverSessions();
2201
+ await this.reconcileCompletedSessions();
2202
+ this.wago.registerCommissioningDiscoveryHandler((controller) => this.claimDiscovered(controller));
2203
+ for (const controller of await this.wago.list()) await this.claimDiscovered(controller);
2204
+ } catch {
2205
+ this.context.logger?.warn("WAGO commissioning recovery failed; automatic discovery claim is disabled.");
2206
+ }
2178
2207
  }
2179
2208
  support() {
2180
2209
  return {
@@ -2192,9 +2221,19 @@ var WagoCommissioningService = class {
2192
2221
  throw new import_common2.ConflictException(
2193
2222
  "no CC100 firmware baseline is configured; commissioning is disabled until an exact supported baseline is set"
2194
2223
  );
2195
- if (!await this.context.getMqttServerConfig(input.mqttServerId))
2196
- throw new import_common2.NotFoundException("MQTT server not found");
2197
- const hostKeyFingerprint = await scanHostKey(input.targetHost);
2224
+ let brokerAvailable;
2225
+ try {
2226
+ brokerAvailable = Boolean(await this.context.getMqttServerConfig(input.mqttServerId));
2227
+ } catch {
2228
+ throw new import_common2.ConflictException("Commissioning MQTT configuration could not be resolved.");
2229
+ }
2230
+ if (!brokerAvailable) throw new import_common2.NotFoundException("MQTT server not found");
2231
+ let hostKeyFingerprint;
2232
+ try {
2233
+ hostKeyFingerprint = await scanHostKey(input.targetHost);
2234
+ } catch {
2235
+ throw new import_common2.ConflictException("Commissioning SSH host-key scan failed.");
2236
+ }
2198
2237
  const hardwareId = `cc100-${(0, import_node_crypto4.createHash)("sha256").update(hostKeyFingerprint).digest("hex").slice(0, 16)}`;
2199
2238
  const now = (/* @__PURE__ */ new Date()).toISOString();
2200
2239
  const session = await this.sessions.save(
@@ -2209,7 +2248,7 @@ var WagoCommissioningService = class {
2209
2248
  enrollmentExpiresAt: null,
2210
2249
  enrollmentId: null,
2211
2250
  // This verifier authenticates the first runtime announcement and is never exposed to an operator.
2212
- pairingCode: (0, import_node_crypto4.randomBytes)(32).toString("base64url"),
2251
+ pairingCode: this.encryptVerifier((0, import_node_crypto4.randomBytes)(32).toString("base64url")),
2213
2252
  codesysState: null,
2214
2253
  progressPercent: 0,
2215
2254
  progressStep: "Confirm controller identity",
@@ -2255,11 +2294,23 @@ var WagoCommissioningService = class {
2255
2294
  if (!session) throw new import_common2.NotFoundException("commissioning session not found");
2256
2295
  if (!["awaiting_delivery", "delivering", "awaiting_codesys_confirmation", "delivery_failed"].includes(session.state))
2257
2296
  throw new import_common2.ConflictException("commissioning session cannot be delivered in its current state");
2297
+ if (input?.confirmInstall !== true)
2298
+ throw new import_common2.ConflictException("explicit installation confirmation is required for every delivery attempt");
2299
+ const credential = input.temporarySsh;
2300
+ if (!credential || typeof credential.username !== "string" || !/^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$/.test(credential.username) || typeof credential.password !== "string" || !credential.password.trim() || /[\r\n\0]/.test(credential.password))
2301
+ throw new import_common2.ConflictException("explicit valid SSH username and password are required for every delivery attempt");
2258
2302
  if (!isRuntimeArtifactConfigured())
2259
2303
  throw new import_common2.ConflictException("no immutable, signed CC100 runtime bundle is configured for commissioning");
2260
- const credential = input.temporarySsh?.username.trim() && input.temporarySsh.password ? input.temporarySsh : defaultSshCredential;
2304
+ let pairingCode;
2305
+ try {
2306
+ pairingCode = this.decryptVerifier(session);
2307
+ } catch {
2308
+ await this.invalidateVerifier(session);
2309
+ return this.toResponse(session);
2310
+ }
2261
2311
  let enrollmentExpiresAt = null;
2262
2312
  try {
2313
+ await this.revokeSessionEnrollment(session);
2263
2314
  session.state = "delivering";
2264
2315
  session.failureReason = null;
2265
2316
  await this.updateProgress(
@@ -2330,12 +2381,12 @@ var WagoCommissioningService = class {
2330
2381
  "Provisioning a restricted MQTT credential for the initial connection."
2331
2382
  );
2332
2383
  const enrollment = await this.wago.createEnrollment(session.hardwareId, session.mqttServerId);
2384
+ session.enrollmentId = enrollment.id;
2385
+ await this.save(session, "enrollment_created");
2333
2386
  if (!enrollment.password)
2334
2387
  throw new import_common2.ConflictException(
2335
2388
  "automatic restricted MQTT credential provisioning is required for secure commissioning"
2336
2389
  );
2337
- if (!session.pairingCode) throw new import_common2.ConflictException("commissioning session has no automatic claim verifier");
2338
- session.enrollmentId = enrollment.id;
2339
2390
  enrollmentExpiresAt = enrollment.expiresAt;
2340
2391
  await this.updateProgress(
2341
2392
  session,
@@ -2353,7 +2404,7 @@ var WagoCommissioningService = class {
2353
2404
  `WAGO_MQTT_USERNAME=${enrollment.username}`,
2354
2405
  `WAGO_MQTT_PASSWORD=${enrollment.password}`,
2355
2406
  `WAGO_ENROLLMENT_SECRET=${enrollment.claimSecret}`,
2356
- `WAGO_PAIRING_CODE=${session.pairingCode}`
2407
+ `WAGO_PAIRING_CODE=${pairingCode}`
2357
2408
  ].join("\n")
2358
2409
  );
2359
2410
  await this.updateProgress(
@@ -2378,18 +2429,16 @@ var WagoCommissioningService = class {
2378
2429
  session.progressStep = "Waiting for controller connection";
2379
2430
  session.progressDetail = "Runtime delivered. Waiting for the controller to connect and complete its automatic claim.";
2380
2431
  return this.toResponse(await this.save(session, "bootstrap_delivered"));
2381
- } catch (error) {
2432
+ } catch {
2382
2433
  if (session.enrollmentId !== null) {
2383
2434
  try {
2384
- await this.wago.revokeEnrollmentById(session.enrollmentId);
2385
- } catch (revocationError) {
2435
+ await this.revokeSessionEnrollment(session);
2436
+ } catch {
2386
2437
  session.state = "delivery_failed";
2387
2438
  session.enrollmentExpiresAt = null;
2388
2439
  session.progressStep = "Delivery failed";
2389
2440
  session.progressDetail = "Credential revocation requires attention before delivery can be retried.";
2390
- session.failureReason = `Secure delivery failed and bootstrap credential revocation requires attention: ${redact(
2391
- revocationError instanceof Error ? revocationError.message : String(revocationError)
2392
- )}`;
2441
+ session.failureReason = "Secure delivery failed; bootstrap credential revocation requires attention.";
2393
2442
  return this.toResponse(await this.save(session, "enrollment_revocation_failed"));
2394
2443
  }
2395
2444
  }
@@ -2397,7 +2446,7 @@ var WagoCommissioningService = class {
2397
2446
  session.enrollmentExpiresAt = null;
2398
2447
  session.progressStep = "Delivery failed";
2399
2448
  session.progressDetail = "Review the error and retry automatic delivery when the controller is reachable.";
2400
- session.failureReason = error instanceof Error ? redact(error.message) : "Secure delivery failed";
2449
+ session.failureReason = "Secure delivery failed. Check controller access and the configured runtime bundle.";
2401
2450
  return this.toResponse(await this.save(session, "delivery_failed"));
2402
2451
  }
2403
2452
  }
@@ -2405,8 +2454,9 @@ var WagoCommissioningService = class {
2405
2454
  return this.withDeliveryLock(id, async () => {
2406
2455
  const session = await this.sessions.findOneBy({ id });
2407
2456
  if (!session) throw new import_common2.NotFoundException("commissioning session not found");
2408
- if (session.enrollmentId !== null) await this.wago.revokeEnrollmentById(session.enrollmentId);
2457
+ await this.revokeSessionEnrollment(session);
2409
2458
  session.state = "revoked";
2459
+ session.pairingCode = null;
2410
2460
  session.failureReason = null;
2411
2461
  return this.toResponse(await this.save(session, "revoked"));
2412
2462
  });
@@ -2416,8 +2466,12 @@ var WagoCommissioningService = class {
2416
2466
  const session = await this.sessions.findOneBy({ id });
2417
2467
  if (!session) throw new import_common2.NotFoundException("commissioning session not found");
2418
2468
  if (session.enrollmentId !== null) {
2419
- await this.wago.revokeEnrollmentById(session.enrollmentId);
2420
- await this.wago.deleteEnrollmentById(session.enrollmentId);
2469
+ try {
2470
+ await this.wago.revokeEnrollmentById(session.enrollmentId);
2471
+ await this.wago.deleteEnrollmentById(session.enrollmentId);
2472
+ } catch {
2473
+ throw new import_common2.ConflictException("Commissioning enrollment removal failed.");
2474
+ }
2421
2475
  }
2422
2476
  await this.sessions.delete(id);
2423
2477
  });
@@ -2439,6 +2493,13 @@ var WagoCommissioningService = class {
2439
2493
  const current = await this.sessions.findOneBy({ id: session.id });
2440
2494
  if (!current || current.state !== "awaiting_discovery" || current.hardwareId !== controller.hardwareId || current.mqttServerId !== controller.mqttServerId || current.enrollmentId !== controller.enrollmentId || !current.controllerName || !current.pairingCode)
2441
2495
  return;
2496
+ let pairingCode;
2497
+ try {
2498
+ pairingCode = this.decryptVerifier(current);
2499
+ } catch {
2500
+ await this.invalidateVerifier(current);
2501
+ return;
2502
+ }
2442
2503
  current.state = "awaiting_claim";
2443
2504
  current.failureReason = null;
2444
2505
  current.progressPercent = 100;
@@ -2446,21 +2507,21 @@ var WagoCommissioningService = class {
2446
2507
  current.progressDetail = "Applying permanent credentials and the reserved controller name.";
2447
2508
  await this.save(current, "automatic_claim_started");
2448
2509
  try {
2449
- await this.wago.claim(controller.id, current.controllerName, current.pairingCode, current.mqttServerId);
2510
+ await this.wago.claim(controller.id, current.controllerName, pairingCode, current.mqttServerId);
2450
2511
  current.state = "completed";
2451
2512
  current.pairingCode = null;
2452
2513
  current.failureReason = null;
2453
2514
  current.progressStep = "Commissioning complete";
2454
2515
  current.progressDetail = "The controller is claimed and ready to configure.";
2455
2516
  await this.save(current, "automatic_claim_completed");
2456
- } catch (error) {
2517
+ } catch {
2457
2518
  current.state = "awaiting_discovery";
2458
- current.failureReason = error instanceof Error ? redact(error.message) : "Automatic claim failed";
2519
+ current.failureReason = "Automatic claim failed.";
2459
2520
  await this.save(current, "automatic_claim_failed");
2460
2521
  return;
2461
2522
  }
2462
2523
  await this.retireSupersededSessions(current.hardwareId, current.id).catch(
2463
- (error) => this.context.logger?.warn(`Could not retire superseded WAGO commissioning sessions: ${String(error)}`)
2524
+ () => this.context.logger?.warn("Could not retire superseded WAGO commissioning sessions.")
2464
2525
  );
2465
2526
  });
2466
2527
  }
@@ -2596,51 +2657,126 @@ printf '%s\\n' "$ATTRACCESS_SSH_PASSWORD"
2596
2657
  progressStep: session.progressStep,
2597
2658
  progressDetail: session.progressDetail,
2598
2659
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2599
- }).catch((error) => this.context.logger?.warn(`Could not update WAGO runtime transfer progress: ${String(error)}`));
2600
- }
2601
- async resumePendingDeliveries() {
2602
- const sessions = await this.sessions.find({ where: [{ state: "awaiting_delivery" }, { state: "delivering" }] });
2603
- for (const session of sessions) void this.deliverInBackground(session.id);
2660
+ }).catch(() => this.context.logger?.warn("Could not update WAGO runtime transfer progress."));
2604
2661
  }
2605
- async reconcileCompletedSessions() {
2606
- const sessions = await this.sessions.find({ where: { state: "completed" } });
2607
- await Promise.all(sessions.map((session) => this.retireSupersededSessions(session.hardwareId, session.id)));
2662
+ encryptVerifier(plaintext) {
2663
+ try {
2664
+ return VERIFIER_PREFIX + this.context.secrets.encrypt(plaintext);
2665
+ } catch {
2666
+ throw new import_common2.ConflictException("Commissioning verifier encryption failed.");
2667
+ }
2608
2668
  }
2609
- async retireSupersededSessions(hardwareId, completedSessionId) {
2610
- const sessions = await this.sessions.find({ where: { hardwareId } });
2611
- await Promise.all(
2612
- sessions.filter(
2613
- (session) => session.id !== completedSessionId && session.state !== "completed" && session.state !== "revoked"
2614
- ).map(
2615
- (session) => this.withDeliveryLock(session.id, async () => {
2616
- const current = await this.sessions.findOneBy({ id: session.id });
2617
- if (!current || current.state === "completed" || current.state === "revoked") return;
2618
- if (current.enrollmentId !== null) await this.wago.revokeEnrollmentById(current.enrollmentId);
2619
- current.state = "revoked";
2620
- current.failureReason = null;
2621
- current.progressStep = "Superseded by completed commissioning";
2622
- current.progressDetail = "A newer commissioning session claimed this controller.";
2623
- await this.save(current, "superseded_by_completed_session");
2624
- })
2625
- )
2626
- );
2669
+ decryptVerifier(session) {
2670
+ try {
2671
+ if (!session.pairingCode?.startsWith(VERIFIER_PREFIX)) throw new Error();
2672
+ const plaintext = this.context.secrets.decrypt(session.pairingCode.slice(VERIFIER_PREFIX.length));
2673
+ if (!/^[A-Za-z0-9_-]{43}$/.test(plaintext)) throw new Error();
2674
+ return plaintext;
2675
+ } catch {
2676
+ throw new import_common2.ConflictException("Commissioning verifier is unavailable; create a new session.");
2677
+ }
2627
2678
  }
2628
- async deliverInBackground(id) {
2679
+ async revokeSessionEnrollment(session) {
2680
+ if (session.enrollmentId == null) return;
2629
2681
  try {
2630
- await this.deliver(id);
2631
- } catch (error) {
2632
- try {
2633
- const session = await this.sessions.findOneBy({ id });
2634
- if (!session || !["awaiting_delivery", "delivering"].includes(session.state)) return;
2635
- session.state = "delivery_failed";
2636
- session.progressStep = "Delivery failed";
2637
- session.progressDetail = "Review the error and retry automatic delivery when the controller is reachable.";
2638
- session.failureReason = error instanceof Error ? redact(error.message) : "Secure delivery failed";
2639
- await this.save(session, "delivery_failed");
2640
- } catch (reportingError) {
2641
- this.context.logger?.warn(`Could not record WAGO commissioning delivery failure: ${String(reportingError)}`);
2682
+ await this.wago.revokeEnrollmentById(session.enrollmentId);
2683
+ await this.wago.deleteEnrollmentById(session.enrollmentId);
2684
+ } catch {
2685
+ throw new import_common2.ConflictException("Commissioning credential revocation requires attention.");
2686
+ }
2687
+ session.enrollmentId = null;
2688
+ session.enrollmentExpiresAt = null;
2689
+ await this.save(session, "enrollment_revoked");
2690
+ }
2691
+ async invalidateVerifier(session) {
2692
+ session.state = "revoked";
2693
+ session.pairingCode = null;
2694
+ session.progressStep = "Commissioning session revoked";
2695
+ session.progressDetail = "The saved verifier is unavailable. Create a new commissioning session.";
2696
+ session.failureReason = "Commissioning verifier is unavailable; credential revocation requires attention.";
2697
+ await this.save(session, "verifier_invalidated");
2698
+ await this.revokeSessionEnrollment(session);
2699
+ session.failureReason = null;
2700
+ await this.save(session, "invalid_verifier_revoked");
2701
+ }
2702
+ async recoverSessions() {
2703
+ let recoveryFailed = false;
2704
+ for (let skip = 0; ; skip += 100) {
2705
+ const page = await this.sessions.find({ order: { id: "ASC" }, take: 100, skip });
2706
+ for (const session of page) {
2707
+ try {
2708
+ if (session.state === "completed" && !session.pairingCode) continue;
2709
+ if (session.state === "revoked" && !session.pairingCode) {
2710
+ await this.revokeSessionEnrollment(session);
2711
+ continue;
2712
+ }
2713
+ try {
2714
+ this.decryptVerifier(session);
2715
+ } catch {
2716
+ await this.invalidateVerifier(session);
2717
+ continue;
2718
+ }
2719
+ if (session.state === "delivering" || session.state === "awaiting_claim") {
2720
+ if (session.state === "awaiting_claim")
2721
+ await this.wago.rollbackInterruptedClaim(session.hardwareId, session.mqttServerId, session.enrollmentId);
2722
+ session.state = "delivery_failed";
2723
+ session.progressStep = "Delivery interrupted";
2724
+ session.progressDetail = "Retry with explicit SSH credentials and installation confirmation.";
2725
+ session.failureReason = "Commissioning was interrupted.";
2726
+ await this.save(session, "delivery_interrupted");
2727
+ await this.revokeSessionEnrollment(session);
2728
+ }
2729
+ } catch {
2730
+ recoveryFailed = true;
2731
+ }
2642
2732
  }
2733
+ if (page.length < 100) break;
2643
2734
  }
2735
+ if (recoveryFailed) throw new import_common2.ConflictException("Commissioning recovery requires credential revocation.");
2736
+ }
2737
+ async reconcileCompletedSessions() {
2738
+ let reconciliationFailed = false;
2739
+ for (let skip = 0; ; skip += 100) {
2740
+ const page = await this.sessions.find({ where: { state: "completed" }, order: { id: "ASC" }, take: 100, skip });
2741
+ for (const session of page) {
2742
+ if (session.state !== "completed") continue;
2743
+ try {
2744
+ await this.retireSupersededSessions(session.hardwareId, session.id);
2745
+ } catch {
2746
+ reconciliationFailed = true;
2747
+ }
2748
+ }
2749
+ if (page.length < 100) break;
2750
+ }
2751
+ if (reconciliationFailed)
2752
+ throw new import_common2.ConflictException("Superseded commissioning credential cleanup requires attention.");
2753
+ }
2754
+ async retireSupersededSessions(hardwareId, completedSessionId) {
2755
+ let retirementFailed = false;
2756
+ for (let skip = 0; ; skip += 100) {
2757
+ const sessions = await this.sessions.find({ where: { hardwareId }, order: { id: "ASC" }, take: 100, skip });
2758
+ const results = await Promise.allSettled(
2759
+ sessions.filter(
2760
+ (session) => session.id !== completedSessionId && session.state !== "completed" && session.state !== "revoked"
2761
+ ).map(
2762
+ (session) => this.withDeliveryLock(session.id, async () => {
2763
+ const current = await this.sessions.findOneBy({ id: session.id });
2764
+ if (!current || current.state === "completed" || current.state === "revoked") return;
2765
+ await this.revokeSessionEnrollment(current);
2766
+ current.state = "revoked";
2767
+ current.pairingCode = null;
2768
+ current.failureReason = null;
2769
+ current.progressStep = "Superseded by completed commissioning";
2770
+ current.progressDetail = "A newer commissioning session claimed this controller.";
2771
+ await this.save(current, "superseded_by_completed_session");
2772
+ })
2773
+ )
2774
+ );
2775
+ if (results.some((result) => result.status === "rejected")) retirementFailed = true;
2776
+ if (sessions.length < 100) break;
2777
+ }
2778
+ if (retirementFailed)
2779
+ throw new import_common2.ConflictException("Superseded commissioning credential cleanup requires attention.");
2644
2780
  }
2645
2781
  toResponse(session) {
2646
2782
  const { pairingCode: _pairingCode, ...response } = session;
@@ -2707,16 +2843,18 @@ function runProcess(command, args, input, environment) {
2707
2843
  return new Promise((resolve, reject) => {
2708
2844
  const child = (0, import_node_child_process.spawn)(command, args, { env: { ...process.env, ...environment }, stdio: ["pipe", "pipe", "pipe"] });
2709
2845
  let stdout = "";
2710
- let stderr = "";
2711
2846
  const timer = setTimeout(() => child.kill(), SSH_TIMEOUT_MS);
2712
2847
  child.stdin.on("error", () => void 0);
2713
2848
  child.stdout.on("data", (chunk) => stdout += chunk);
2714
- child.stderr.on("data", (chunk) => stderr += chunk);
2715
- child.on("error", reject);
2849
+ child.stderr.on("data", () => void 0);
2850
+ child.on("error", () => {
2851
+ clearTimeout(timer);
2852
+ reject(new Error("Commissioning subprocess failed."));
2853
+ });
2716
2854
  child.on("close", (code) => {
2717
2855
  clearTimeout(timer);
2718
2856
  if (code === 0) resolve(stdout);
2719
- else reject(new Error(stderr || `${command} exited with status ${code}`));
2857
+ else reject(new Error("Commissioning subprocess failed."));
2720
2858
  });
2721
2859
  child.stdin.end(input);
2722
2860
  });
@@ -2726,7 +2864,6 @@ async function uploadFile(source, args, environment, onProgress) {
2726
2864
  return new Promise((resolve, reject) => {
2727
2865
  const child = (0, import_node_child_process.spawn)("ssh", args, { env: { ...process.env, ...environment }, stdio: ["pipe", "ignore", "pipe"] });
2728
2866
  const stream = (0, import_node_fs.createReadStream)(source);
2729
- let stderr = "";
2730
2867
  let transferred = 0;
2731
2868
  let lastPercent = -1;
2732
2869
  let settled = false;
@@ -2736,13 +2873,13 @@ async function uploadFile(source, args, environment, onProgress) {
2736
2873
  settled = true;
2737
2874
  clearTimeout(timer);
2738
2875
  stream.destroy();
2739
- if (error) reject(error);
2876
+ if (error) reject(new Error("Commissioning upload failed."));
2740
2877
  else resolve();
2741
2878
  };
2742
2879
  child.stdin.on("error", () => void 0);
2743
- child.stderr.on("data", (chunk) => stderr += chunk);
2880
+ child.stderr.on("data", () => void 0);
2744
2881
  child.on("error", finish);
2745
- child.on("close", (code) => finish(code === 0 ? void 0 : new Error(stderr || `ssh exited with status ${code}`)));
2882
+ child.on("close", (code) => finish(code === 0 ? void 0 : new Error("Commissioning upload failed.")));
2746
2883
  stream.on("error", (error) => {
2747
2884
  child.kill();
2748
2885
  finish(error);
@@ -2766,9 +2903,6 @@ function isPrivateAddress(host) {
2766
2903
  if (octets.some((octet) => octet > 255)) return false;
2767
2904
  return octets[0] === 10 || octets[0] === 192 && octets[1] === 168 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;
2768
2905
  }
2769
- function redact(value) {
2770
- return value.replace(/(?:password|secret|token)=?[^\s]*/gi, "[redacted]");
2771
- }
2772
2906
  function isImmutableImage(image) {
2773
2907
  return /@sha256:[a-f0-9]{64}$/i.test(image);
2774
2908
  }
@@ -2882,8 +3016,13 @@ var WagoControllerApi = class {
2882
3016
  return this.commissioning.confirmHostKey(id, body.hostKeyFingerprint);
2883
3017
  }
2884
3018
  deliverCommissioningSession(id, body) {
3019
+ if (body?.confirmInstall !== true) throw new import_common3.BadRequestException("Explicit installation consent is required");
3020
+ if (typeof body.temporarySsh?.username !== "string" || !body.temporarySsh.username.trim() || typeof body.temporarySsh.password !== "string" || !body.temporarySsh.password) {
3021
+ throw new import_common3.BadRequestException("Temporary SSH username and password are required");
3022
+ }
2885
3023
  return this.commissioning.deliver(id, {
2886
- temporarySsh: body?.temporarySsh ? { username: body.temporarySsh.username ?? "", password: body.temporarySsh.password ?? "" } : void 0
3024
+ confirmInstall: true,
3025
+ temporarySsh: { username: body.temporarySsh.username, password: body.temporarySsh.password }
2887
3026
  });
2888
3027
  }
2889
3028
  revokeCommissioningSession(id) {
@@ -83,7 +83,7 @@ var confirmCommissioningHostKey = (id, hostKeyFingerprint) => api.request(`/comm
83
83
  body: { hostKeyFingerprint }
84
84
  });
85
85
  var listCommissioningSessions = (limit = 100, offset = 0) => api.request(`/commissioning/sessions?limit=${limit}&offset=${offset}`);
86
- var deliverCommissioningSession = (id, input = {}) => api.request(`/commissioning/sessions/${id}/deliver`, {
86
+ var deliverCommissioningSession = (id, input) => api.request(`/commissioning/sessions/${id}/deliver`, {
87
87
  method: "POST",
88
88
  body: input
89
89
  });
@@ -183,7 +183,20 @@ function useConfirmCommissioningHostKeyMutation() {
183
183
  function useDeliverCommissioningSessionMutation() {
184
184
  const queryClient = useQueryClient();
185
185
  return useMutation({
186
- mutationFn: ({ id, ...input }) => deliverCommissioningSession(id, input),
186
+ gcTime: 0,
187
+ retry: false,
188
+ networkMode: "always",
189
+ mutationFn: (variables) => {
190
+ const temporarySsh = { ...variables.temporarySsh };
191
+ const confirmInstall = variables.confirmInstall;
192
+ variables.temporarySsh.password = "";
193
+ variables.confirmInstall = false;
194
+ if (confirmInstall !== true) throw new Error("Explicit installation consent is required");
195
+ return deliverCommissioningSession(variables.id, {
196
+ confirmInstall,
197
+ temporarySsh
198
+ });
199
+ },
187
200
  onSuccess: async () => {
188
201
  await queryClient.invalidateQueries({ queryKey: queryKeys.controllers });
189
202
  await queryClient.invalidateQueries({ queryKey: queryKeys.commissioningSessions });
@@ -324,7 +337,7 @@ function getErrorMessage$1(error) {
324
337
  }
325
338
  //#endregion
326
339
  //#region frontend/src/ConfigurationEditor.tsx
327
- var { Alert: Alert$4, Button: Button$5, Card, Checkbox, Form: Form$1, Input: Input$1, Label: Label$2, Modal: Modal$1, ModalBackdrop: ModalBackdrop$1, ModalBody: ModalBody$1, ModalContainer: ModalContainer$1, ModalDialog: ModalDialog$1, ModalFooter: ModalFooter$1, ModalHeader: ModalHeader$1, ModalHeading: ModalHeading$1, TextArea, TextField: TextField$1 } = await importShared("@heroui/react");
340
+ var { Alert: Alert$4, Button: Button$5, Card, Checkbox: Checkbox$1, Form: Form$1, Input: Input$1, Label: Label$2, Modal: Modal$1, ModalBackdrop: ModalBackdrop$1, ModalBody: ModalBody$1, ModalContainer: ModalContainer$1, ModalDialog: ModalDialog$1, ModalFooter: ModalFooter$1, ModalHeader: ModalHeader$1, ModalHeading: ModalHeading$1, TextArea, TextField: TextField$1 } = await importShared("@heroui/react");
328
341
  var { useEffect: useEffect$3, useRef, useState: useState$3 } = await importShared("react");
329
342
  var emptySnapshot = {
330
343
  version: 1,
@@ -518,7 +531,7 @@ function ConfigurationEditor({ controllerId, onOpenChange }) {
518
531
  className: "wg:text-sm wg:font-medium",
519
532
  children: "Select preset changes to copy into this draft"
520
533
  }),
521
- presetPreview.diff.map((change) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Checkbox, {
534
+ presetPreview.diff.map((change) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Checkbox$1, {
522
535
  isDisabled: isApplyingPreset,
523
536
  isSelected: selectedPaths.includes(change.path),
524
537
  onChange: (selected) => setSelectedPaths((paths) => selected ? [...paths, change.path] : paths.filter((path) => path !== change.path)),
@@ -800,7 +813,7 @@ function StandardDrawer({ isOpen, onOpenChange, children }) {
800
813
  }
801
814
  //#endregion
802
815
  //#region frontend/src/CommissioningModal.tsx
803
- var { Alert: Alert$3, Button: Button$3, DrawerBody: DrawerBody$1, DrawerFooter: DrawerFooter$1, DrawerHeader: DrawerHeader$1, Input, Label: Label$1, ListBox: ListBox$1, Select: Select$1, Spinner: Spinner$2, TextField } = await importShared("@heroui/react");
816
+ var { Alert: Alert$3, Button: Button$3, Checkbox, DrawerBody: DrawerBody$1, DrawerFooter: DrawerFooter$1, DrawerHeader: DrawerHeader$1, Input, Label: Label$1, ListBox: ListBox$1, Select: Select$1, Spinner: Spinner$2, TextField } = await importShared("@heroui/react");
804
817
  var { AlertCircleIcon, CheckCircle2Icon, CpuIcon: CpuIcon$1 } = await importShared("lucide-react");
805
818
  var { useEffect: useEffect$2, useState: useState$2 } = await importShared("react");
806
819
  function CommissioningModal({ isOpen, session: resumedSession, onOpenChange }) {
@@ -817,14 +830,20 @@ function CommissioningModal({ isOpen, session: resumedSession, onOpenChange }) {
817
830
  const [controllerIp, setControllerIp] = useState$2("");
818
831
  const [mqttServerId, setMqttServerId] = useState$2(null);
819
832
  const [hostKeyFingerprint, setHostKeyFingerprint] = useState$2("");
820
- const [sshUsername, setSshUsername] = useState$2("root");
821
- const [sshPassword, setSshPassword] = useState$2("wago");
833
+ const [sshUsername, setSshUsername] = useState$2("");
834
+ const [sshPassword, setSshPassword] = useState$2("");
835
+ const [confirmInstall, setConfirmInstall] = useState$2(false);
822
836
  const [isCancelConfirmationOpen, setCancelConfirmationOpen] = useState$2(false);
823
837
  const mutationSession = deliverSessionMutation.data ?? resumedSession ?? createdSession;
824
838
  const session = mutationSession ? commissioningSessionsQuery.data?.find((candidate) => candidate.id === mutationSession.id) ?? mutationSession : null;
825
839
  const selectedMqttServerId = mqttServerId === null ? null : Number(mqttServerId);
826
840
  const isLoading = createSessionMutation.isPending || confirmHostKeyMutation.isPending || deliverSessionMutation.isPending || removeSessionMutation.isPending;
827
- const loadingStatus = createSessionMutation.isPending ? ["Preparing commissioning", "Scanning and pinning the controller SSH identity."] : removeSessionMutation.isPending ? ["Canceling enrollment", "Revoking access and removing the enrollment records."] : confirmHostKeyMutation.isPending ? ["Confirming controller identity", "Saving the administrator-confirmed SSH host key."] : null;
841
+ const loadingStatus = createSessionMutation.isPending ? ["Preparing commissioning", "Scanning the SSH key for your review. A scan alone does not authenticate the controller."] : removeSessionMutation.isPending ? ["Canceling enrollment", "Revoking access and removing the enrollment records."] : confirmHostKeyMutation.isPending ? ["Confirming controller identity", "Saving the administrator-confirmed SSH host key."] : null;
842
+ useEffect$2(() => {
843
+ setSshPassword("");
844
+ setConfirmInstall(false);
845
+ setHostKeyFingerprint("");
846
+ }, [isOpen, resumedSession?.id]);
828
847
  useEffect$2(() => {
829
848
  if (!isOpen) return;
830
849
  setMqttServerId(resumedSession?.mqttServerId.toString() ?? settingsQuery.data?.defaultMqttServerId?.toString() ?? null);
@@ -843,10 +862,12 @@ function CommissioningModal({ isOpen, session: resumedSession, onOpenChange }) {
843
862
  setControllerIp("");
844
863
  setMqttServerId(null);
845
864
  setHostKeyFingerprint("");
846
- setSshUsername("root");
847
- setSshPassword("wago");
865
+ setSshUsername("");
866
+ setSshPassword("");
867
+ setConfirmInstall(false);
848
868
  setCancelConfirmationOpen(false);
849
869
  createSessionMutation.reset();
870
+ confirmHostKeyMutation.reset();
850
871
  deliverSessionMutation.reset();
851
872
  removeSessionMutation.reset();
852
873
  onOpenChange(false);
@@ -863,14 +884,17 @@ function CommissioningModal({ isOpen, session: resumedSession, onOpenChange }) {
863
884
  } });
864
885
  }
865
886
  function deliverSession() {
866
- if (!session) return;
887
+ if (!session || isLoading || !confirmInstall || !sshUsername.trim() || !sshPassword || !canInstall(session)) return;
867
888
  deliverSessionMutation.mutate({
868
889
  id: session.id,
890
+ confirmInstall: true,
869
891
  temporarySsh: {
870
- username: sshUsername,
892
+ username: sshUsername.trim(),
871
893
  password: sshPassword
872
894
  }
873
895
  });
896
+ setSshPassword("");
897
+ setConfirmInstall(false);
874
898
  }
875
899
  function confirmHostKey() {
876
900
  if (!session) return;
@@ -925,7 +949,9 @@ function CommissioningModal({ isOpen, session: resumedSession, onOpenChange }) {
925
949
  sshUsername,
926
950
  sshPassword,
927
951
  onSshUsernameChange: setSshUsername,
928
- onSshPasswordChange: setSshPassword
952
+ onSshPasswordChange: setSshPassword,
953
+ confirmInstall,
954
+ onConfirmInstallChange: setConfirmInstall
929
955
  }),
930
956
  session && activeStep === 3 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ProgressStep, {
931
957
  name: title,
@@ -956,22 +982,22 @@ function CommissioningModal({ isOpen, session: resumedSession, onOpenChange }) {
956
982
  isPending: isLoading,
957
983
  isDisabled: !controllerIp.trim() || selectedMqttServerId === null || mqttServersQuery.isPending || mqttServersQuery.isError,
958
984
  onPress: createSession,
959
- children: isLoading ? "Preparing commissioning" : "Start automatic commissioning"
985
+ children: isLoading ? "Preparing commissioning" : "Scan controller for review"
960
986
  }),
961
987
  session?.state === "awaiting_identity_confirmation" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$3, {
962
988
  isPending: isLoading,
963
- isDisabled: hostKeyFingerprint !== session.hostKeyFingerprint,
989
+ isDisabled: !hostKeyFingerprint || hostKeyFingerprint !== session.hostKeyFingerprint,
964
990
  onPress: confirmHostKey,
965
991
  children: isLoading ? "Confirming identity" : "Confirm host key"
966
992
  }),
967
- session && ["awaiting_delivery", "delivery_failed"].includes(session.state) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$3, {
993
+ session && canInstall(session) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$3, {
968
994
  isPending: isLoading,
969
- isDisabled: !sshUsername.trim() || !sshPassword,
995
+ isDisabled: isLoading || !confirmInstall || !sshUsername.trim() || !sshPassword,
970
996
  onPress: deliverSession,
971
- children: isLoading ? "Starting delivery" : session.state === "delivery_failed" ? "Retry delivery" : "Start secure delivery"
997
+ children: isLoading ? "Starting installation" : session.state === "delivery_failed" ? "Retry installation" : "Install runtime"
972
998
  }),
973
999
  session && session.state !== "completed" && session.state !== "revoked" && (isCancelConfirmationOpen ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$3, {
974
- color: "danger",
1000
+ variant: "danger",
975
1001
  isPending: isLoading,
976
1002
  onPress: () => removeSessionMutation.mutate(session.id, { onSuccess: close }),
977
1003
  children: isLoading ? "Canceling enrollment" : "Confirm cancellation"
@@ -989,7 +1015,7 @@ function DevicePassport({ className, name, step }) {
989
1015
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("aside", {
990
1016
  className: `wg:min-w-0 wg:rounded-large wg:bg-default-100 wg:p-5 ${className ?? ""}`,
991
1017
  children: [
992
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CpuIcon$1, { className: "wg:h-10 wg:w-10 wg:text-accent-soft-foreground" }),
1018
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CpuIcon$1, { className: "wg:h-10 wg:w-10 wg:text-primary" }),
993
1019
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
994
1020
  className: "wg:mt-4 wg:text-xs wg:font-semibold wg:uppercase wg:tracking-wider wg:text-muted",
995
1021
  children: "CC100 device passport"
@@ -1003,15 +1029,15 @@ function DevicePassport({ className, name, step }) {
1003
1029
  children: [
1004
1030
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PassportRow, {
1005
1031
  label: "Identity",
1006
- value: step >= 2 ? "Verified automatically" : "Not scanned"
1032
+ value: step >= 2 ? "Review required" : "Not scanned"
1007
1033
  }),
1008
1034
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PassportRow, {
1009
1035
  label: "Runtime",
1010
- value: step >= 3 ? "Provisioning" : "Not delivered"
1036
+ value: step >= 2 ? "See session status" : "Not installed"
1011
1037
  }),
1012
1038
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PassportRow, {
1013
1039
  label: "Claim",
1014
- value: step >= 3 ? "Automatic" : "Queued"
1040
+ value: step >= 3 ? "See session status" : "Pending"
1015
1041
  })
1016
1042
  ]
1017
1043
  }),
@@ -1022,7 +1048,7 @@ function DevicePassport({ className, name, step }) {
1022
1048
  1,
1023
1049
  2,
1024
1050
  3
1025
- ].map((index) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `wg:h-1.5 wg:flex-1 wg:rounded-full ${index <= step ? "wg:bg-accent" : "wg:bg-default-300"}` }, index))
1051
+ ].map((index) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `wg:h-1.5 wg:flex-1 wg:rounded-full ${index <= step ? "wg:bg-primary" : "wg:bg-default-300"}` }, index))
1026
1052
  })
1027
1053
  ]
1028
1054
  });
@@ -1039,9 +1065,9 @@ function PassportRow({ label, value }) {
1039
1065
  function StepHeading({ step }) {
1040
1066
  const content = [
1041
1067
  ["Give this controller a useful name", "This name is reserved now and applied automatically when the controller comes online."],
1042
- ["Connect it securely", "Enter the private address and MQTT server. The controller identity is verified automatically."],
1043
- ["Verifying and provisioning", "The server pins the SSH fingerprint automatically and installs the runtime without an operator confirmation."],
1044
- ["Commissioning continues automatically", "You can close this window and start another controller at any time."]
1068
+ ["Connect the controller", "Enter its private address and choose an MQTT server. Review the scanned identity before installation."],
1069
+ ["Review and approve installation", "Confirm the controller identity, enter temporary SSH credentials, and approve the changes for this attempt."],
1070
+ ["Commissioning status", "A saved session does not authorize a new installation. After an interruption, review the status before retrying."]
1045
1071
  ][step];
1046
1072
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
1047
1073
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", {
@@ -1065,7 +1091,7 @@ function StepHeading({ step }) {
1065
1091
  function OperationStatus({ title, description }) {
1066
1092
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1067
1093
  "aria-live": "polite",
1068
- className: "wg:rounded-large wg:border wg:border-accent/30 wg:bg-accent/5 wg:p-3",
1094
+ className: "wg:rounded-large wg:border wg:border-primary/30 wg:bg-primary/5 wg:p-3",
1069
1095
  children: [
1070
1096
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1071
1097
  className: "wg:flex wg:items-center wg:gap-2",
@@ -1083,7 +1109,7 @@ function OperationStatus({ title, description }) {
1083
1109
  }),
1084
1110
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1085
1111
  className: "wg:mt-3 wg:h-1 wg:overflow-hidden wg:rounded-full wg:bg-default-200",
1086
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "wg:h-full wg:w-2/5 wg:animate-pulse wg:rounded-full wg:bg-accent" })
1112
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "wg:h-full wg:w-2/5 wg:animate-pulse wg:rounded-full wg:bg-primary" })
1087
1113
  })
1088
1114
  ]
1089
1115
  });
@@ -1103,93 +1129,139 @@ function NameStep({ name, onNameChange }) {
1103
1129
  function ConnectionStep({ controllerIp, mqttServerId, mqttServersQuery, onControllerIpChange, onMqttServerIdChange }) {
1104
1130
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1105
1131
  className: "wg:space-y-4",
1106
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
1107
- isRequired: true,
1108
- name: "controller-ip",
1109
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "Controller IP address" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
1110
- value: controllerIp,
1111
- placeholder: "192.168.1.42",
1112
- onChange: (event) => onControllerIpChange(event.target.value)
1113
- })]
1114
- }), mqttServersQuery.isPending ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1115
- className: "wg:flex wg:justify-center wg:p-2",
1116
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner$2, {
1117
- color: "accent",
1118
- size: "sm"
1132
+ children: [
1133
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3, {
1134
+ status: "accent",
1135
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Indicator, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3.Content, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Title, { children: "Prepare the CC100 (751-9301), firmware 31" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Description, { children: "Have the controller powered using its specified supply and wait for it to finish starting. Connect its Ethernet cable to the intended local network. Ask your network administrator for its assigned IP address and ensure the Attraccess server can reach it. Keep power and networking connected during installation. If the model or firmware is uncertain, check the device label and your maintenance records with the responsible administrator before proceeding." })] })]
1136
+ }),
1137
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
1138
+ isRequired: true,
1139
+ name: "controller-ip",
1140
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "Controller IP address" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
1141
+ value: controllerIp,
1142
+ placeholder: "192.168.1.42",
1143
+ onChange: (event) => onControllerIpChange(event.target.value)
1144
+ })]
1145
+ }),
1146
+ mqttServersQuery.isPending ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1147
+ className: "wg:flex wg:justify-center wg:p-2",
1148
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner$2, {
1149
+ color: "accent",
1150
+ size: "sm"
1151
+ })
1152
+ }) : mqttServersQuery.isError ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorAlert$1, { error: mqttServersQuery.error }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Select$1, {
1153
+ className: "wg:w-full",
1154
+ name: "mqttServerId",
1155
+ placeholder: "Select an MQTT server",
1156
+ value: mqttServerId,
1157
+ onChange: onMqttServerIdChange,
1158
+ children: [
1159
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "MQTT server" }),
1160
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Select$1.Trigger, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Select$1.Value, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Select$1.Indicator, {})] }),
1161
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Select$1.Popover, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ListBox$1, {
1162
+ renderEmptyState: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
1163
+ className: "wg:block wg:p-3 wg:text-sm wg:text-muted",
1164
+ children: "No MQTT servers configured."
1165
+ }),
1166
+ children: (mqttServersQuery.data ?? []).map((server) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(ListBox$1.Item, {
1167
+ id: server.id.toString(),
1168
+ textValue: server.name,
1169
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1170
+ className: "wg:min-w-0 wg:truncate",
1171
+ children: server.name
1172
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ListBox$1.ItemIndicator, {})]
1173
+ }, server.id))
1174
+ }) })
1175
+ ]
1119
1176
  })
1120
- }) : mqttServersQuery.isError ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorAlert$1, { error: mqttServersQuery.error }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Select$1, {
1121
- className: "wg:w-full",
1122
- name: "mqttServerId",
1123
- placeholder: "Select an MQTT server",
1124
- value: mqttServerId,
1125
- onChange: onMqttServerIdChange,
1126
- children: [
1127
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "MQTT server" }),
1128
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Select$1.Trigger, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Select$1.Value, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Select$1.Indicator, {})] }),
1129
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Select$1.Popover, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ListBox$1, {
1130
- renderEmptyState: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
1131
- className: "wg:block wg:p-3 wg:text-sm wg:text-muted",
1132
- children: "No MQTT servers configured."
1133
- }),
1134
- children: (mqttServersQuery.data ?? []).map((server) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(ListBox$1.Item, {
1135
- id: server.id.toString(),
1136
- textValue: server.name,
1137
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1138
- className: "wg:min-w-0 wg:truncate",
1139
- children: server.name
1140
- }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ListBox$1.ItemIndicator, {})]
1141
- }, server.id))
1142
- }) })
1143
- ]
1144
- })]
1177
+ ]
1145
1178
  });
1146
1179
  }
1147
1180
  function HostKeyConfirmationStep({ fingerprint, expectedFingerprint, onFingerprintChange }) {
1148
1181
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1149
1182
  className: "wg:space-y-4",
1150
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3, {
1151
- status: "warning",
1152
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Indicator, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3.Content, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Title, { children: "Verify the controller SSH key" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Description, { children: "Compare this fingerprint with the value shown on the physical controller or from a trusted inventory record. Enter it exactly to authorize delivery." })] })]
1153
- }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
1154
- isRequired: true,
1155
- name: "host-key-fingerprint",
1156
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "Scanned SSH host-key fingerprint" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
1157
- value: fingerprint,
1158
- placeholder: expectedFingerprint,
1159
- onChange: (event) => onFingerprintChange(event.target.value)
1160
- })]
1161
- })]
1183
+ children: [
1184
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3, {
1185
+ status: "warning",
1186
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Indicator, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3.Content, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Title, { children: "Review the controller SSH key" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Description, { children: "Compare the scanned fingerprint with a trusted inventory record or a fingerprint obtained independently through a trusted administrator. Copying the scan back here is not independent authentication. If no trusted fingerprint is available, confirm the physical controller and its cabling on an isolated network you control; this reduces exposure but does not independently authenticate the SSH key. Stop if the device or network is uncertain. Confirming the key does not start installation." })] })]
1187
+ }),
1188
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", {
1189
+ className: "wg:break-all wg:text-sm",
1190
+ children: ["Scanned fingerprint: ", expectedFingerprint]
1191
+ }),
1192
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
1193
+ isRequired: true,
1194
+ name: "host-key-fingerprint",
1195
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "Reviewed SSH host-key fingerprint" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
1196
+ value: fingerprint,
1197
+ onChange: (event) => onFingerprintChange(event.target.value)
1198
+ })]
1199
+ })
1200
+ ]
1162
1201
  });
1163
1202
  }
1164
- function DeliveryStep({ isDelivering, session, sshUsername, sshPassword, onSshUsernameChange, onSshPasswordChange }) {
1203
+ function canInstall(session) {
1204
+ return [
1205
+ "awaiting_delivery",
1206
+ "delivery_failed",
1207
+ "awaiting_codesys_confirmation"
1208
+ ].includes(session.state);
1209
+ }
1210
+ function DeliveryStep({ isDelivering, session, sshUsername, sshPassword, onSshUsernameChange, onSshPasswordChange, confirmInstall, onConfirmInstallChange }) {
1165
1211
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1166
1212
  className: "wg:space-y-4",
1167
1213
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CommissioningStatusPanel, {
1168
1214
  isActive: isDelivering || session.state === "delivering",
1169
1215
  session
1170
- }), ["awaiting_delivery", "delivery_failed"].includes(session.state) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1171
- className: "wg:grid wg:gap-4 wg:sm:grid-cols-2",
1172
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
1173
- isRequired: true,
1174
- name: "ssh-username",
1175
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "Temporary SSH username" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
1176
- value: sshUsername,
1177
- onChange: (event) => onSshUsernameChange(event.target.value)
1216
+ }), canInstall(session) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1217
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3, {
1218
+ status: "warning",
1219
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Indicator, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3.Content, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Alert$3.Title, { children: "Review this installation attempt" }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3.Description, { children: [
1220
+ "Installing on ",
1221
+ session.targetHost,
1222
+ " can interrupt CODESYS and replaces an existing Attraccess runtime container. Make sure connected equipment can safely tolerate the interruption and replacement. Installation does not certify the controller as hardened or the connected equipment as ready for use."
1223
+ ] })] })]
1224
+ }),
1225
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1226
+ className: "wg:grid wg:gap-4 wg:sm:grid-cols-2",
1227
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
1228
+ isRequired: true,
1229
+ isDisabled: isDelivering,
1230
+ name: "ssh-username",
1231
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "Temporary SSH username" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
1232
+ autoComplete: "off",
1233
+ value: sshUsername,
1234
+ onChange: (event) => onSshUsernameChange(event.target.value)
1235
+ })]
1236
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
1237
+ isRequired: true,
1238
+ isDisabled: isDelivering,
1239
+ name: "ssh-password",
1240
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "Temporary SSH password" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
1241
+ autoComplete: "off",
1242
+ type: "password",
1243
+ value: sshPassword,
1244
+ onChange: (event) => onSshPasswordChange(event.target.value)
1245
+ })]
1178
1246
  })]
1179
- }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
1247
+ }),
1248
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Checkbox, {
1180
1249
  isRequired: true,
1181
- name: "ssh-password",
1182
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label$1, { children: "Temporary SSH password" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
1183
- type: "password",
1184
- value: sshPassword,
1185
- onChange: (event) => onSshPasswordChange(event.target.value)
1186
- })]
1187
- })]
1188
- })]
1250
+ isDisabled: isDelivering,
1251
+ isSelected: confirmInstall,
1252
+ onChange: onConfirmInstallChange,
1253
+ name: "confirm-install",
1254
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Checkbox.Content, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Checkbox.Control, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Checkbox.Indicator, {}) }), "I approve CODESYS interruption and replacement of the existing Attraccess runtime container for this installation attempt."] })
1255
+ }),
1256
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
1257
+ className: "wg:text-sm wg:text-muted",
1258
+ children: "Enter the controller credentials explicitly; no default credentials are used. The password and approval are cleared after submitting or closing. Every retry needs a new approval and password. Restarting the server does not start or resume an installation."
1259
+ })
1260
+ ] })]
1189
1261
  });
1190
1262
  }
1191
1263
  function ProgressStep({ name, session }) {
1192
- const complete = session.state === "completed";
1264
+ const complete = session.state === "completed" || session.state === "revoked";
1193
1265
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1194
1266
  className: "wg:space-y-4",
1195
1267
  children: [
@@ -1209,7 +1281,7 @@ function ProgressStep({ name, session }) {
1209
1281
  children: "Safe to close"
1210
1282
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
1211
1283
  className: "wg:mt-1 wg:text-muted",
1212
- children: "This session is saved in the CC100 devices table. Start another commissioning session while this one continues in the background."
1284
+ children: "This session is saved in the CC100 devices table. Closing this window does not cancel an installation already submitted. If installation is interrupted, reopening the session or restarting the server does not authorize another attempt."
1213
1285
  })]
1214
1286
  }),
1215
1287
  session.failureReason && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert$3, {
@@ -1223,11 +1295,11 @@ function CommissioningStatusPanel({ isActive, session }) {
1223
1295
  const percent = session.progressPercent ?? 0;
1224
1296
  const isQueued = session.state === "awaiting_delivery";
1225
1297
  const hasFailure = !isActive && Boolean(session.failureReason);
1226
- const title = isQueued ? "Delivery queued" : session.progressStep ?? (isActive ? "Preparing commissioning" : commissioningLabel(session.state));
1227
- const detail = isQueued ? "The server will begin or resume this saved delivery automatically." : session.progressDetail ?? "Waiting for the next commissioning operation.";
1298
+ const title = isQueued ? "Installation approval required" : session.progressStep ?? (isActive ? "Preparing commissioning" : commissioningLabel(session.state));
1299
+ const detail = isQueued ? "Review the installation, enter temporary SSH credentials, and approve this attempt. Saved sessions do not start or resume installation automatically." : session.progressDetail ?? "Waiting for the next commissioning operation.";
1228
1300
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1229
1301
  "aria-live": "polite",
1230
- className: "wg:rounded-large wg:border wg:border-accent/30 wg:bg-accent/5 wg:p-4",
1302
+ className: "wg:rounded-large wg:border wg:border-primary/30 wg:bg-primary/5 wg:p-4",
1231
1303
  children: [
1232
1304
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1233
1305
  className: "wg:flex wg:items-start wg:gap-3",
@@ -1254,7 +1326,7 @@ function CommissioningStatusPanel({ isActive, session }) {
1254
1326
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1255
1327
  className: "wg:mt-3 wg:h-1.5 wg:overflow-hidden wg:rounded-full wg:bg-default-200",
1256
1328
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1257
- className: `wg:h-full wg:rounded-full wg:transition-[width] wg:duration-500 ${hasFailure ? "wg:bg-danger" : "wg:bg-accent"}`,
1329
+ className: `wg:h-full wg:rounded-full wg:transition-[width] wg:duration-500 ${hasFailure ? "wg:bg-danger" : "wg:bg-primary"}`,
1258
1330
  style: { width: `${percent}%` }
1259
1331
  })
1260
1332
  })
@@ -9,7 +9,7 @@ var exportSet = /* @__PURE__ */ new Set([
9
9
  ]);
10
10
  var moduleMap = { "./plugin": () => {
11
11
  dynamicLoadingCss(["style.css"], false, "./plugin");
12
- return __federation_import("./__federation_expose_Plugin-BdPTyEH_.js").then((module) => Object.keys(module).every((item) => exportSet.has(item)) ? () => module.default : () => module);
12
+ return __federation_import("./__federation_expose_Plugin-CVjUpNsq.js").then((module) => Object.keys(module).every((item) => exportSet.has(item)) ? () => module.default : () => module);
13
13
  } };
14
14
  var seen = {};
15
15
  var dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
@@ -259,17 +259,7 @@
259
259
  border-top-width: 1px;
260
260
  }
261
261
 
262
- .wg\:border-accent\/30 {
263
- border-color: var(--accent);
264
- }
265
-
266
- @supports (color: color-mix(in lab, red, red)) {
267
- .wg\:border-accent\/30 {
268
- border-color: color-mix(in oklab, var(--accent) 30%, transparent);
269
- }
270
- }
271
-
272
- .wg\:bg-accent, .wg\:bg-accent\/5 {
262
+ .wg\:bg-accent\/5 {
273
263
  background-color: var(--accent);
274
264
  }
275
265
 
@@ -371,6 +361,10 @@
371
361
  letter-spacing: var(--wg-tracking-wider);
372
362
  }
373
363
 
364
+ .wg\:break-all {
365
+ word-break: break-all;
366
+ }
367
+
374
368
  .wg\:text-accent-soft-foreground {
375
369
  color: var(--accent-soft-foreground);
376
370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@attraccess/plugin-wago",
3
- "version": "0.1.0-nightly.941.1",
3
+ "version": "0.1.0-nightly.943.1",
4
4
  "description": "Official Attraccess integration for WAGO CC100 controllers.",
5
5
  "keywords": [
6
6
  "attraccess-plugin",