home-hosted 0.8.2 → 0.9.0

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.
Files changed (35) hide show
  1. package/AGENTS.md +9 -5
  2. package/README.md +50 -14
  3. package/dist/cli.js +1331 -282
  4. package/dist/cli.js.map +1 -1
  5. package/docs/DDNS.md +2 -1
  6. package/docs/NOTIFICATIONS.md +72 -20
  7. package/docs/SERVERS.md +20 -1
  8. package/docs/UI_CREATION.md +22 -7
  9. package/docs/branding/logo.svg +33 -0
  10. package/docs/branding/social-preview.png +0 -0
  11. package/package.json +27 -24
  12. package/uis/stock/dist/assets/de-DL4xkOYj.js +1 -0
  13. package/uis/stock/dist/assets/es-CKj-v3FY.js +1 -0
  14. package/uis/stock/dist/assets/fr-72WremqT.js +1 -0
  15. package/uis/stock/dist/assets/index-BNSSU69y.js +47 -0
  16. package/uis/stock/dist/assets/index-GxTN2fYn.css +2 -0
  17. package/uis/stock/dist/assets/ja-BU4XyMbC.js +1 -0
  18. package/uis/stock/dist/assets/ko-Db8xtJ0N.js +1 -0
  19. package/uis/stock/dist/assets/ru-Bs8KXP2O.js +1 -0
  20. package/uis/stock/dist/assets/vi-DdQD4h7B.js +1 -0
  21. package/uis/stock/dist/assets/zh-CN-DIrw2Ubv.js +1 -0
  22. package/uis/stock/dist/favicon.ico +0 -0
  23. package/uis/stock/dist/favicon.svg +14 -0
  24. package/uis/stock/dist/index.html +6 -2
  25. package/uis/stock/dist/ui.json +3 -3
  26. package/uis/stock/dist/assets/de-DQxDtYp2.js +0 -1
  27. package/uis/stock/dist/assets/es-BP0qzCTU.js +0 -1
  28. package/uis/stock/dist/assets/fr-DHCvxiys.js +0 -1
  29. package/uis/stock/dist/assets/index-C02FUAwz.js +0 -46
  30. package/uis/stock/dist/assets/index-Skf2FzpK.css +0 -2
  31. package/uis/stock/dist/assets/ja-DseIeS1D.js +0 -1
  32. package/uis/stock/dist/assets/ko-Dcu2b6c2.js +0 -1
  33. package/uis/stock/dist/assets/ru-DaAAVw09.js +0 -1
  34. package/uis/stock/dist/assets/vi-D780h-t9.js +0 -1
  35. package/uis/stock/dist/assets/zh-CN-C8OC-N1b.js +0 -1
package/dist/cli.js CHANGED
@@ -709,7 +709,22 @@ function parseBind(value) {
709
709
  const parsed = bindSchema(value);
710
710
  return parsed instanceof type.errors ? null : parsed;
711
711
  }
712
- var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, labelOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, telegramChatsSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restoreSkipSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, healthResponseSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
712
+ /**
713
+ * The ids that appear more than once, so a write can refuse them.
714
+ *
715
+ * A schema-level check is not available here (see above), and this is the one shape that has to
716
+ * be told apart from a plain validation error: the list is valid, but its identity is not.
717
+ */
718
+ function duplicateWebhookIds(targets) {
719
+ const seen = /* @__PURE__ */ new Set();
720
+ const duplicates = /* @__PURE__ */ new Set();
721
+ for (const target of targets) {
722
+ if (seen.has(target.id)) duplicates.add(target.id);
723
+ seen.add(target.id);
724
+ }
725
+ return [...duplicates];
726
+ }
727
+ var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, labelOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, notificationPolicyShape, notificationDefaultsSchema, useOwnSettingsSchema, telegramDestinationSchema, webhookTargetSchema, notificationTargetSchema, destinationListSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupScheduleSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, notificationPolicyPatchShape, destinationPatchSchema, notificationsPatchSchema, hostPatchSchema, backupSchedulePatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, entryStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, notificationPolicyStatusShape, destinationStatusSchema, telegramChatsSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyActorSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restoreSkipSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, healthResponseSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, destinationIdParamSchema, destinationTestSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
713
728
  var init_contracts = __esmMin((() => {
714
729
  bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
715
730
  workspaceIdSchema = type("/^[a-z0-9][a-z0-9_-]*$/");
@@ -735,6 +750,10 @@ var init_contracts = __esmMin((() => {
735
750
  resetAfterMs: "number >= 0 = 60000"
736
751
  }).onUndeclaredKey("reject");
737
752
  httpCheckSchema = type({
753
+ /** `https` is for a backend that serves TLS itself; pair it with `allowInvalidCert` when it is self-signed. */
754
+ scheme: "\"http\" | \"https\" = \"http\"",
755
+ /** Accept any certificate. A self-signed pair is the normal case here, and the probe is local. */
756
+ allowInvalidCert: "boolean = false",
738
757
  /** Path on the server's own port, e.g. `/healthz`. */
739
758
  path: "string = \"/\"",
740
759
  method: "\"GET\" | \"HEAD\" = \"GET\"",
@@ -831,9 +850,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
831
850
  maxLoginAttempts: "number.integer >= 1 = 5",
832
851
  lockoutMs: "number >= 1000 = 60000"
833
852
  }).onUndeclaredKey("reject");
834
- telegramSchema = type({
835
- enabled: "boolean = false",
836
- chatId: "string = \"\"",
853
+ notificationPolicyShape = {
837
854
  onCrash: "boolean = true",
838
855
  onUnhealthy: "boolean = true",
839
856
  onForcedRestart: "boolean = true",
@@ -844,8 +861,44 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
844
861
  onDdns: "boolean = true",
845
862
  /** Per server *and* reason, so a flapping server cannot spam the chat. */
846
863
  cooldownMs: "number >= 0 = 120000"
864
+ };
865
+ notificationDefaultsSchema = type({ ...notificationPolicyShape }).onUndeclaredKey("reject");
866
+ useOwnSettingsSchema = "boolean = true";
867
+ telegramDestinationSchema = type({
868
+ kind: "\"telegram\"",
869
+ id: "/^[a-z0-9][a-z0-9_-]*$/",
870
+ /** Off until configured: a bare `enabled: true` with no chat would fail every send. */
871
+ enabled: "boolean = false",
872
+ chatId: "string = \"\"",
873
+ useOwnSettings: useOwnSettingsSchema,
874
+ ...notificationPolicyShape
875
+ }).onUndeclaredKey("reject");
876
+ webhookTargetSchema = type({
877
+ kind: "\"webhook\"",
878
+ id: "/^[a-z0-9][a-z0-9_-]*$/",
879
+ /** Empty only while the target is a draft; the page will not save one without it. */
880
+ url: "string = \"\"",
881
+ enabled: "boolean = true",
882
+ method: "\"POST\" | \"PUT\" = \"POST\"",
883
+ /** Payload format: `json` sends the event as JSON, `text` sends the plain message. */
884
+ format: "\"json\" | \"text\" = \"json\"",
885
+ /**
886
+ * Extra headers, `Name: value` per line. The one field that lets this transport talk to
887
+ * ntfy (`Title`, `Priority`), a bearer-token endpoint (`Authorization`) or anything else
888
+ * with a header-shaped contract.
889
+ */
890
+ headers: "string = \"\"",
891
+ useOwnSettings: useOwnSettingsSchema,
892
+ ...notificationPolicyShape
893
+ }).onUndeclaredKey("reject");
894
+ notificationTargetSchema = telegramDestinationSchema.or(webhookTargetSchema);
895
+ destinationListSchema = notificationTargetSchema.array();
896
+ notificationsSchema = type({
897
+ /** What every destination inherits unless it carries its own settings. */
898
+ defaults: notificationDefaultsSchema.default(() => ({})),
899
+ /** Replaced by a patch, the same way `ddns.accounts` is: a merge cannot express removing one. */
900
+ destinations: destinationListSchema.default(() => [])
847
901
  }).onUndeclaredKey("reject");
848
- notificationsSchema = type({ telegram: telegramSchema.default(() => ({})) }).onUndeclaredKey("reject");
849
902
  logsSchema = type({
850
903
  persist: "boolean = true",
851
904
  /** Per server, before rotating to `.1`, `.2`, ... */
@@ -970,12 +1023,20 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
970
1023
  accounts: ddnsAccountSchema.array().optional(),
971
1024
  domains: ddnsDomainSchema.array().optional()
972
1025
  }).onUndeclaredKey("reject");
1026
+ backupScheduleSchema = type({
1027
+ /** 0 turns the schedule off; otherwise the gap between automatic archives. */
1028
+ intervalMs: "number.integer >= 0 = 0",
1029
+ /** How many automatic archives to keep. Manual ones are never pruned by it. */
1030
+ keep: "number.integer >= 1 = 7"
1031
+ }).onUndeclaredKey("reject");
973
1032
  backupsSchema = type({
974
1033
  enabled: "boolean = true",
975
1034
  dir: "string = \".backups\"",
1035
+ /** How many archives to keep in total, newest first. */
976
1036
  keep: "number.integer >= 1 = 5",
977
1037
  /** Extra paths in every backup, in addition to each server's `backupPaths`. */
978
- includePaths: type("string[]").default(() => [])
1038
+ includePaths: type("string[]").default(() => []),
1039
+ schedule: backupScheduleSchema.default(() => ({}))
979
1040
  }).onUndeclaredKey("reject");
980
1041
  controlSchema = type({
981
1042
  /** What the panel calls itself; the stock UI shows it in the sidebar. */
@@ -1006,6 +1067,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1006
1067
  resetAfterMs: "number >= 0?"
1007
1068
  }).onUndeclaredKey("reject");
1008
1069
  httpCheckPatchSchema = type({
1070
+ scheme: "\"http\" | \"https\"?",
1071
+ allowInvalidCert: "boolean?",
1009
1072
  path: "string?",
1010
1073
  method: "\"GET\" | \"HEAD\"?",
1011
1074
  expectStatus: "number.integer | null?",
@@ -1037,9 +1100,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1037
1100
  maxLoginAttempts: "number.integer >= 1?",
1038
1101
  lockoutMs: "number >= 1000?"
1039
1102
  }).onUndeclaredKey("reject");
1040
- telegramPatchSchema = type({
1041
- enabled: "boolean?",
1042
- chatId: "string?",
1103
+ notificationPolicyPatchShape = {
1043
1104
  onCrash: "boolean?",
1044
1105
  onUnhealthy: "boolean?",
1045
1106
  onForcedRestart: "boolean?",
@@ -1047,8 +1108,36 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1047
1108
  onHost: "boolean?",
1048
1109
  onDdns: "boolean?",
1049
1110
  cooldownMs: "number >= 0?"
1111
+ };
1112
+ destinationPatchSchema = type({
1113
+ kind: "\"telegram\" | \"webhook\"",
1114
+ id: "/^[a-z0-9][a-z0-9_-]*$/",
1115
+ enabled: "boolean?",
1116
+ useOwnSettings: "boolean?",
1117
+ /** Telegram only. */
1118
+ chatId: "string?",
1119
+ /** Webhook only. */
1120
+ url: "string?",
1121
+ method: "\"POST\" | \"PUT\"?",
1122
+ format: "\"json\" | \"text\"?",
1123
+ headers: "string?",
1124
+ ...notificationPolicyPatchShape
1125
+ }).onUndeclaredKey("reject");
1126
+ notificationsPatchSchema = type({
1127
+ /** Merged key by key, like the other nested groups. */
1128
+ /**
1129
+ * A merge key-by-key, and **every field optional** — `notificationPolicyPatchShape`, not a
1130
+ * `.partial()` of the defaults schema.
1131
+ *
1132
+ * ArkType's `.partial()` leaves a field's `= false` / `= 120000` default in place, so a body of
1133
+ * `{ onHost: false }` validated into all seven keys and one toggle overwrote the rest. A patch
1134
+ * schema here carries no defaults (the rule for every patch), and the store merges it over the
1135
+ * stored block.
1136
+ */
1137
+ defaults: type({ ...notificationPolicyPatchShape }).onUndeclaredKey("reject").optional(),
1138
+ /** Replaced whole, so a removed destination stays removed. */
1139
+ destinations: destinationPatchSchema.array().optional()
1050
1140
  }).onUndeclaredKey("reject");
1051
- notificationsPatchSchema = type({ telegram: telegramPatchSchema.optional() }).onUndeclaredKey("reject");
1052
1141
  hostPatchSchema = type({
1053
1142
  enabled: "boolean?",
1054
1143
  intervalMs: "number >= 5000?",
@@ -1059,11 +1148,16 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1059
1148
  loadPerCpu: "number >= 0?",
1060
1149
  tempCelsius: "number >= 0?"
1061
1150
  }).onUndeclaredKey("reject");
1151
+ backupSchedulePatchSchema = type({
1152
+ intervalMs: "number.integer >= 0?",
1153
+ keep: "number.integer >= 1?"
1154
+ }).onUndeclaredKey("reject");
1062
1155
  backupsPatchSchema = type({
1063
1156
  enabled: "boolean?",
1064
1157
  dir: "string?",
1065
1158
  keep: "number.integer >= 1?",
1066
- includePaths: type("string[]").optional()
1159
+ includePaths: type("string[]").optional(),
1160
+ schedule: backupSchedulePatchSchema.optional()
1067
1161
  }).onUndeclaredKey("reject");
1068
1162
  logsPatchSchema = type({
1069
1163
  persist: "boolean?",
@@ -1154,6 +1248,19 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1154
1248
  "heartbeatAt": "number",
1155
1249
  "lastExit?": nannyExitSchema
1156
1250
  }).onUndeclaredKey("reject");
1251
+ entryStateSchema = type({
1252
+ /** Attempts spent on the current crash sequence; reset once the process stays up. */
1253
+ "restarts": "number.integer >= 0",
1254
+ /** The `runOnce` bootstrap already ran for this entry. */
1255
+ "bootstrapDone": "boolean",
1256
+ /**
1257
+ * The last child this panel spawned for a non-persistent entry. A detached child
1258
+ * outlives the panel, so this is the only way a later boot recognizes its own
1259
+ * survivor instead of spawning a second copy beside it.
1260
+ */
1261
+ "lastPid?": "number | null",
1262
+ "lastStartedAt?": "number | null"
1263
+ });
1157
1264
  settingsPatchSchema = type({
1158
1265
  control: controlPatchSchema.optional(),
1159
1266
  host: hostPatchSchema.optional(),
@@ -1371,10 +1478,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1371
1478
  privateKey: "string >= 1"
1372
1479
  }).onUndeclaredKey("reject");
1373
1480
  proxyEngineInstallSchema = type({ version: "string = \"\"" }).onUndeclaredKey("reject");
1374
- telegramStatusSchema = type({
1375
- enabled: "boolean",
1376
- tokenSet: "boolean",
1377
- chatId: "string",
1481
+ notificationPolicyStatusShape = {
1378
1482
  onCrash: "boolean",
1379
1483
  onUnhealthy: "boolean",
1380
1484
  onForcedRestart: "boolean",
@@ -1383,8 +1487,24 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1383
1487
  onHost: "boolean",
1384
1488
  /** Dynamic DNS, when this panel knows about it (optional for an older panel). */
1385
1489
  onDdns: "boolean?",
1386
- cooldownMs: "number",
1387
- /** Last delivery outcome, for the settings page. */
1490
+ cooldownMs: "number"
1491
+ };
1492
+ destinationStatusSchema = type({
1493
+ id: "string",
1494
+ kind: "\"telegram\" | \"webhook\"",
1495
+ enabled: "boolean",
1496
+ useOwnSettings: "boolean",
1497
+ ...notificationPolicyStatusShape,
1498
+ /** Telegram only, null on a webhook. */
1499
+ chatId: "string | null",
1500
+ /** Telegram only: whether a bot token is stored for this destination. */
1501
+ tokenSet: "boolean",
1502
+ /** Webhook only, null on a Telegram destination. */
1503
+ url: "string | null",
1504
+ method: "\"POST\" | \"PUT\" | null",
1505
+ format: "\"json\" | \"text\" | null",
1506
+ headers: "string | null",
1507
+ /** Last delivery outcome, for the page. */
1388
1508
  lastResult: "string | null",
1389
1509
  lastResultAt: "number | null"
1390
1510
  });
@@ -1392,7 +1512,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1392
1512
  id: "string | number",
1393
1513
  title: "string"
1394
1514
  }).array() });
1395
- notificationViewSchema = type({ telegram: telegramStatusSchema });
1515
+ notificationViewSchema = type({
1516
+ /** The general block: what a destination inherits unless it overrides it. Optional for an older panel. */
1517
+ "defaults?": notificationDefaultsSchema,
1518
+ /** Every destination, in config order. Optional for an older panel. */
1519
+ "destinations?": destinationStatusSchema.array()
1520
+ });
1396
1521
  sessionViewSchema = type({
1397
1522
  authenticated: "boolean",
1398
1523
  authRequired: "boolean",
@@ -1419,13 +1544,16 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1419
1544
  stream: logStreamSchema,
1420
1545
  text: "string"
1421
1546
  });
1547
+ historyActorSchema = type.enumerated("policy", "user", "agent", "boot");
1422
1548
  historyEventSchema = type({
1423
- serverId: "string",
1424
- ts: "number",
1425
- type: "\"start\" | \"exit\" | \"crash\" | \"forced-restart\" | \"unhealthy\" | \"recovered\"",
1426
- detail: "string",
1549
+ "serverId": "string",
1550
+ "ts": "number",
1551
+ "type": "\"start\" | \"exit\" | \"crash\" | \"forced-restart\" | \"unhealthy\" | \"recovered\"",
1552
+ "detail": "string",
1427
1553
  /** How long the process had been up, recorded on exit and crash. */
1428
- runtimeMs: "number?"
1554
+ "runtimeMs": "number?",
1555
+ /** Who caused it; absent on events from before this field existed. */
1556
+ "actor?": historyActorSchema
1429
1557
  });
1430
1558
  serverHistorySchema = type({
1431
1559
  windowMs: "number",
@@ -1497,6 +1625,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1497
1625
  "labelParams?": "Record<string, string>",
1498
1626
  /** `settings`/`servers`/`secrets` for a workspace, `data` for a declared path. */
1499
1627
  "kind": "\"settings\" | \"servers\" | \"secrets\" | \"data\"",
1628
+ /** Which secret a `secrets` leaf carries (`password`, `apiToken`, `telegram`, `ddns`, …). */
1629
+ "secret?": "string",
1500
1630
  "path?": "string",
1501
1631
  /** Who declared a data path: `global`, `<serverId>:backupPaths` or `<serverId>:<ENV>`. */
1502
1632
  "origin?": "string",
@@ -1508,14 +1638,24 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1508
1638
  }).array()
1509
1639
  });
1510
1640
  backupsViewSchema = type({
1511
- enabled: "boolean",
1512
- dir: "string",
1513
- keep: "number",
1641
+ "enabled": "boolean",
1642
+ "dir": "string",
1643
+ "keep": "number",
1644
+ /**
1645
+ * The scheduled half, so the page can show and edit it.
1646
+ *
1647
+ * Optional because the panel and the UI upgrade separately: an older panel does not send it,
1648
+ * and a required field would blank the settings page for that panel. The page reads it as
1649
+ * "off, keep 7" when absent — the schema defaults — so nothing changes for it.
1650
+ */
1651
+ "schedule?": backupScheduleSchema,
1652
+ /** When the last automatic archive was written; null until one has run. Optional for the same reason. */
1653
+ "lastAutomaticAt?": "number | null",
1514
1654
  /** Extra paths from the global config, in addition to each server's own. */
1515
- includePaths: type("string[]"),
1655
+ "includePaths": type("string[]"),
1516
1656
  /** Every selectable entry, global first, then each workspace. */
1517
- entries: backupEntrySchema.array(),
1518
- files: backupFileSchema.array()
1657
+ "entries": backupEntrySchema.array(),
1658
+ "files": backupFileSchema.array()
1519
1659
  });
1520
1660
  restoreItemSchema = type({
1521
1661
  /** A backup entry or leaf id (`workspace:<id>:servers`, `data:<path>`, ...). */
@@ -1546,6 +1686,14 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1546
1686
  "encrypted": "boolean",
1547
1687
  /** The archive needs a password (none or a wrong one was supplied). */
1548
1688
  "needsPassword": "boolean",
1689
+ /**
1690
+ * Where the archive says it came from. Optional: an archive written before this
1691
+ * stamp existed carries neither field, and an older panel never sends the row.
1692
+ */
1693
+ "archive?": type({
1694
+ "writtenBy?": "string",
1695
+ "schema?": "number"
1696
+ }),
1549
1697
  "items": restoreItemSchema.array(),
1550
1698
  "applied": type("string[]"),
1551
1699
  /**
@@ -1636,11 +1784,11 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1636
1784
  "files?": "number.integer >= 1",
1637
1785
  /** `owner/name` of the UI's own repository, for `ui-update`. */
1638
1786
  "repo?": "string",
1639
- /** The release tag this build came from, e.g. `v0.6.0`. */
1787
+ /** The release tag this build came from, e.g. `v0.6.0`. Stamped by the build, never committed. */
1640
1788
  "tag?": "string",
1641
1789
  /** The release asset name, e.g. `home-hosted-ui-noc-console`. */
1642
1790
  "asset?": "string",
1643
- /** When the UI was built, in unix epoch seconds. */
1791
+ /** When the UI was built, in unix epoch seconds. Stamped by the build, never committed. */
1644
1792
  "unix?": "number.integer >= 0"
1645
1793
  });
1646
1794
  uiStatusSchema = type({
@@ -1740,10 +1888,19 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1740
1888
  lines: logLineSchema.array()
1741
1889
  });
1742
1890
  notificationActionSchema = type({
1743
- /** Optional override, so the token can be tested before it is saved. */
1891
+ /** Optional override, so the token can be tried before it is saved. */
1744
1892
  botToken: "string?",
1745
1893
  chatId: "string?"
1746
1894
  }).onUndeclaredKey("reject");
1895
+ destinationIdParamSchema = type({ id: "/^[a-z0-9][a-z0-9_-]*$/" });
1896
+ destinationTestSchema = type({
1897
+ target: webhookTargetSchema.omit("kind", "id").and(type({
1898
+ id: "string?",
1899
+ kind: "\"webhook\"?"
1900
+ })),
1901
+ /** Echoed so the result is recorded against the right row; absent for a draft. */
1902
+ id: "string?"
1903
+ }).onUndeclaredKey("reject");
1747
1904
  telegramTokenSchema = type({ botToken: "string >= 1" }).onUndeclaredKey("reject");
1748
1905
  apiErrorSchema = type({
1749
1906
  "message": "string",
@@ -1941,6 +2098,20 @@ function isPlainDdns(value) {
1941
2098
  return "plain" in value;
1942
2099
  }
1943
2100
  /**
2101
+ * The bot tokens by destination id.
2102
+ *
2103
+ * Two shapes read: the map this release writes, and the single `{ botToken }` written before a
2104
+ * workspace could have more than one Telegram destination. The old one arrives under the id the
2105
+ * config migration gave the destination it came from, so an upgrade keeps a working Telegram.
2106
+ */
2107
+ function readTelegramTokens(value) {
2108
+ if (!isRecord(value)) return {};
2109
+ if (typeof value.botToken === "string") return value.botToken.trim().length > 0 ? { [LEGACY_TELEGRAM_ID$1]: value.botToken.trim() } : {};
2110
+ const tokens = {};
2111
+ for (const [id, token] of Object.entries(value)) if (typeof token === "string" && token.trim().length > 0) tokens[id] = token.trim();
2112
+ return tokens;
2113
+ }
2114
+ /**
1944
2115
  * Sealed on disk, or `{ provider, values }` waiting for the sealing write. A bare
1945
2116
  * field map — written before the provider was recorded — no longer reads, because
1946
2117
  * there is nothing to say which provider those fields belong to.
@@ -2022,7 +2193,7 @@ function openDdns(accountId, sealed, key) {
2022
2193
  return null;
2023
2194
  }
2024
2195
  }
2025
- var COST, KEYLEN, SALT_BYTES, DDNS_KEYLEN, DDNS_IV_BYTES, DDNS_AAD_PREFIX, DDNS_SEALED_ALGO, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
2196
+ var COST, KEYLEN, SALT_BYTES, LEGACY_TELEGRAM_ID$1, DDNS_KEYLEN, DDNS_IV_BYTES, DDNS_AAD_PREFIX, DDNS_SEALED_ALGO, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
2026
2197
  var init_secrets = __esmMin((() => {
2027
2198
  init_atomic();
2028
2199
  init_logger();
@@ -2035,6 +2206,7 @@ var init_secrets = __esmMin((() => {
2035
2206
  };
2036
2207
  KEYLEN = 64;
2037
2208
  SALT_BYTES = 16;
2209
+ LEGACY_TELEGRAM_ID$1 = "telegram";
2038
2210
  DDNS_KEYLEN = 32;
2039
2211
  DDNS_IV_BYTES = 12;
2040
2212
  DDNS_AAD_PREFIX = "hh-ddns-v1:";
@@ -2112,11 +2284,17 @@ var init_secrets = __esmMin((() => {
2112
2284
  get apiTokenHint() {
2113
2285
  return this.apiToken?.hint ?? null;
2114
2286
  }
2115
- get telegramToken() {
2116
- return this.load().telegram?.botToken ?? null;
2287
+ /** The bot token for one destination, or null when it has none. */
2288
+ telegramToken(id) {
2289
+ const token = this.load().telegram[id] ?? "";
2290
+ return token.length > 0 ? token : null;
2291
+ }
2292
+ telegramTokenSet(id) {
2293
+ return this.telegramToken(id) !== null;
2117
2294
  }
2118
- get telegramTokenSet() {
2119
- return (this.telegramToken ?? "").length > 0;
2295
+ /** Every destination id a token is stored for — including one the config no longer declares. */
2296
+ get telegramTokenIds() {
2297
+ return Object.keys(this.load().telegram).sort();
2120
2298
  }
2121
2299
  setPassword(password, options = {}) {
2122
2300
  const record = hashPassword(password, options);
@@ -2151,11 +2329,24 @@ var init_secrets = __esmMin((() => {
2151
2329
  apiToken: null
2152
2330
  });
2153
2331
  }
2154
- setTelegramToken(token) {
2332
+ setTelegramToken(id, token) {
2155
2333
  const trimmed = token?.trim() ?? "";
2334
+ const current = { ...this.load().telegram };
2335
+ if (trimmed.length > 0) current[id] = trimmed;
2336
+ else delete current[id];
2156
2337
  this.save({
2157
2338
  ...this.load(),
2158
- telegram: trimmed.length > 0 ? { botToken: trimmed } : null
2339
+ telegram: current
2340
+ });
2341
+ }
2342
+ /** Drops every token whose destination is gone, so a deleted target leaves nothing behind. */
2343
+ forgetTelegramTokens(keep) {
2344
+ const allowed = new Set(keep);
2345
+ const current = this.load().telegram;
2346
+ const next = Object.fromEntries(Object.entries(current).filter(([id]) => allowed.has(id)));
2347
+ if (Object.keys(next).length !== Object.keys(current).length) this.save({
2348
+ ...this.load(),
2349
+ telegram: next
2159
2350
  });
2160
2351
  }
2161
2352
  /** Ids that have something stored, whether or not a config still declares them. */
@@ -2260,7 +2451,7 @@ var init_secrets = __esmMin((() => {
2260
2451
  const empty = {
2261
2452
  password: null,
2262
2453
  apiToken: null,
2263
- telegram: null,
2454
+ telegram: {},
2264
2455
  ddns: {},
2265
2456
  ddnsKdf: null
2266
2457
  };
@@ -2277,7 +2468,7 @@ var init_secrets = __esmMin((() => {
2277
2468
  return this.inScope({
2278
2469
  password: parsed?.password ?? null,
2279
2470
  apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
2280
- telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null,
2471
+ telegram: readTelegramTokens(parsed?.telegram),
2281
2472
  ddns: readStoredDdns(parsed?.ddns),
2282
2473
  ddnsKdf: readDdnsKdf(parsed?.ddnsKdf)
2283
2474
  });
@@ -2290,7 +2481,7 @@ var init_secrets = __esmMin((() => {
2290
2481
  inScope(contents) {
2291
2482
  if (this.scope === "global") return {
2292
2483
  ...contents,
2293
- telegram: null,
2484
+ telegram: {},
2294
2485
  ddns: {},
2295
2486
  ddnsKdf: null
2296
2487
  };
@@ -2635,6 +2826,14 @@ function createAuthGuard(deps) {
2635
2826
  await next();
2636
2827
  };
2637
2828
  }
2829
+ /**
2830
+ * Who is behind a request, as a history event names them: a cookie is a person at
2831
+ * the panel, a bearer token is a script or an agent. Anonymous callers can only be
2832
+ * loopback requests while auth is unarmed, which is the browser's own origin.
2833
+ */
2834
+ function requestActor(c, auth) {
2835
+ return requestIdentity(c, auth).method === "token" ? "agent" : "user";
2836
+ }
2638
2837
  var PUBLIC_PATHS, AUTH_REQUIRED_CODE;
2639
2838
  var init_auth = __esmMin((() => {
2640
2839
  init_loopback();
@@ -3087,7 +3286,10 @@ function buildWorkspaceView(source) {
3087
3286
  logsDir: source.logsDir,
3088
3287
  defaults: source.store.defaults,
3089
3288
  logs: source.store.logs,
3090
- notifications: { telegram: source.notifications.status() },
3289
+ notifications: {
3290
+ defaults: source.notifications.defaults(),
3291
+ destinations: source.notifications.destinations()
3292
+ },
3091
3293
  ddns: source.ddns.view,
3092
3294
  serverCount: servers.length,
3093
3295
  runningCount: servers.filter((server) => server.status === "running").length,
@@ -3643,7 +3845,7 @@ function createControlRoute(deps) {
3643
3845
  }
3644
3846
  }), validate("query", workspaceQuerySchema), async (c) => {
3645
3847
  assertLocalCall(c, "start or stop a server");
3646
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.param("id"));
3848
+ const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.param("id"), { actor: "user" });
3647
3849
  return c.json(result, statusForAction(result));
3648
3850
  }).post("/servers/:id/stop", describeRoute({
3649
3851
  tags: ["panel"],
@@ -3667,7 +3869,7 @@ function createControlRoute(deps) {
3667
3869
  }
3668
3870
  }), validate("query", workspaceQuerySchema), async (c) => {
3669
3871
  assertLocalCall(c, "restart a server");
3670
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.param("id"));
3872
+ const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.param("id"), { actor: "user" });
3671
3873
  return c.json(result, statusForAction(result));
3672
3874
  });
3673
3875
  }
@@ -3683,9 +3885,55 @@ var init_control = __esmMin((() => {
3683
3885
  }));
3684
3886
  //#endregion
3685
3887
  //#region src/config/migrations.ts
3888
+ /**
3889
+ * Schema 1 → 2: `notifications.{telegram, webhooks}` → `notifications.{defaults, destinations}`.
3890
+ *
3891
+ * The old singleton becomes one `telegram` destination, and each webhook keeps its id, so a
3892
+ * destination's cooldown, last-result record and Telegram token all still point at the same thing.
3893
+ * `useOwnSettings` is set **true** on every migrated destination: before this schema each one
3894
+ * carried its own toggles, and reading one as "inherit" would silently change what it delivers.
3895
+ * The old webhook `enabled` default (true) and the Telegram one (false) are preserved as they were.
3896
+ */
3897
+ function notificationsToDestinations(config) {
3898
+ const notifications = config.notifications;
3899
+ if (notifications === null || typeof notifications !== "object" || Array.isArray(notifications)) return config;
3900
+ const group = notifications;
3901
+ if (group.destinations !== void 0 || group.telegram === void 0 && group.webhooks === void 0) return config;
3902
+ const destinations = [];
3903
+ const telegram = group.telegram;
3904
+ if (telegram !== null && typeof telegram === "object") {
3905
+ const { enabled, chatId, ...policy } = telegram;
3906
+ destinations.push({
3907
+ kind: "telegram",
3908
+ id: LEGACY_TELEGRAM_ID,
3909
+ enabled: enabled ?? false,
3910
+ chatId: chatId ?? "",
3911
+ useOwnSettings: true,
3912
+ ...policy
3913
+ });
3914
+ }
3915
+ if (Array.isArray(group.webhooks)) for (const entry of group.webhooks) {
3916
+ if (entry === null || typeof entry !== "object") continue;
3917
+ destinations.push({
3918
+ kind: "webhook",
3919
+ useOwnSettings: true,
3920
+ ...entry
3921
+ });
3922
+ }
3923
+ const next = {
3924
+ ...group,
3925
+ destinations
3926
+ };
3927
+ delete next.telegram;
3928
+ delete next.webhooks;
3929
+ return {
3930
+ ...config,
3931
+ notifications: next
3932
+ };
3933
+ }
3686
3934
  /** What would have to run to bring `from` up to `to`. */
3687
3935
  function planConfigMigrations(from, options = {}) {
3688
- const to = options.to ?? 1;
3936
+ const to = options.to ?? 2;
3689
3937
  const steps = (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to);
3690
3938
  return {
3691
3939
  from,
@@ -3695,19 +3943,42 @@ function planConfigMigrations(from, options = {}) {
3695
3943
  reached: steps.at(-1)?.to ?? from
3696
3944
  };
3697
3945
  }
3698
- /** Applies the plan in order; the caller owns writing the result. */
3946
+ /**
3947
+ * Applies the plan in order; the caller owns writing the result.
3948
+ *
3949
+ * The result carries `meta.schema` set to the schema the steps reached, so it is readable by this
3950
+ * release's own parser — which refuses a file whose stamp still says an older schema. Without that,
3951
+ * `migrate` validated its own output as "needs 1 migration" and refused every config it exists to
3952
+ * lift.
3953
+ */
3699
3954
  function applyConfigMigrations(config, from, options = {}) {
3700
- const { steps } = planConfigMigrations(from, options);
3955
+ const plan = planConfigMigrations(from, options);
3701
3956
  let current = config;
3702
- for (const step of steps) current = step.apply(current);
3703
- return {
3957
+ for (const step of plan.steps) current = step.apply(current);
3958
+ if (plan.steps.length === 0) return {
3704
3959
  config: current,
3705
- applied: steps
3960
+ applied: plan.steps
3961
+ };
3962
+ const meta = current.meta !== null && typeof current.meta === "object" && !Array.isArray(current.meta) ? current.meta : {};
3963
+ return {
3964
+ config: {
3965
+ ...current,
3966
+ meta: {
3967
+ ...meta,
3968
+ schema: plan.reached
3969
+ }
3970
+ },
3971
+ applied: plan.steps
3706
3972
  };
3707
3973
  }
3708
- var configMigrations;
3974
+ var LEGACY_TELEGRAM_ID, configMigrations;
3709
3975
  var init_migrations = __esmMin((() => {
3710
- configMigrations = [];
3976
+ LEGACY_TELEGRAM_ID = "telegram";
3977
+ configMigrations = [{
3978
+ to: 2,
3979
+ describe: "notifications: fold the Telegram singleton and the webhook list into one `destinations` list, with a shared `defaults` block",
3980
+ apply: notificationsToDestinations
3981
+ }];
3711
3982
  }));
3712
3983
  //#endregion
3713
3984
  //#region src/config/parse.ts
@@ -3771,10 +4042,10 @@ function readStamp(raw, options) {
3771
4042
  const meta = isRecord(raw.meta) ? raw.meta : null;
3772
4043
  const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
3773
4044
  const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
3774
- if (schemaVersion > 1) return {
4045
+ if (schemaVersion > 2) return {
3775
4046
  schemaVersion,
3776
4047
  writtenBy,
3777
- error: `written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`
4048
+ error: `written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 2`
3778
4049
  };
3779
4050
  const { steps } = planConfigMigrations(schemaVersion, options);
3780
4051
  if (steps.length > 0) return {
@@ -3931,6 +4202,7 @@ function validateCrossField(servers) {
3931
4202
  for (const server of servers) {
3932
4203
  const { health } = server;
3933
4204
  if (health.mode === "http" && health.http.method === "HEAD" && health.http.expectBody.length > 0) errors.push(`"${server.id}" requires a response body with method HEAD, which has none — the body check is ignored`);
4205
+ if (health.mode === "http" && health.http.scheme === "http" && health.http.allowInvalidCert) errors.push(`"${server.id}" sets allowInvalidCert on an http probe, where there is no certificate to check`);
3934
4206
  for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
3935
4207
  else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
3936
4208
  }
@@ -3958,7 +4230,7 @@ function stampConfig(draft) {
3958
4230
  ...$schema === void 0 ? {} : { $schema },
3959
4231
  meta: {
3960
4232
  writtenBy: appVersion(),
3961
- schema: 1
4233
+ schema: 2
3962
4234
  },
3963
4235
  ...rest
3964
4236
  };
@@ -4009,7 +4281,7 @@ function mergeGroup(target, patch) {
4009
4281
  }
4010
4282
  return merged;
4011
4283
  }
4012
- var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, PROXY_MERGE_KEYS, EMPTY_MERGE_KEYS;
4284
+ var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, PROXY_MERGE_KEYS, BACKUP_MERGE_KEYS, EMPTY_MERGE_KEYS;
4013
4285
  var init_patch = __esmMin((() => {
4014
4286
  init_shape();
4015
4287
  SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
@@ -4018,9 +4290,10 @@ var init_patch = __esmMin((() => {
4018
4290
  "stop"
4019
4291
  ]);
4020
4292
  CONTROL_MERGE_KEYS = /* @__PURE__ */ new Set(["auth", "tls"]);
4021
- NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["telegram"]);
4293
+ NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["defaults"]);
4022
4294
  DDNS_MERGE_KEYS = /* @__PURE__ */ new Set(["ipv4", "ipv6"]);
4023
4295
  PROXY_MERGE_KEYS = /* @__PURE__ */ new Set(["dns01"]);
4296
+ BACKUP_MERGE_KEYS = /* @__PURE__ */ new Set(["schedule"]);
4024
4297
  EMPTY_MERGE_KEYS = /* @__PURE__ */ new Set();
4025
4298
  }));
4026
4299
  //#endregion
@@ -4210,7 +4483,7 @@ var init_settings$1 = __esmMin((() => {
4210
4483
  updateBackups(patch) {
4211
4484
  const draft = structuredClone(this.raw);
4212
4485
  draft.backups = { ...draft.backups ?? {} };
4213
- applyPatch(draft.backups, patch, EMPTY_MERGE_KEYS);
4486
+ applyPatch(draft.backups, patch, BACKUP_MERGE_KEYS);
4214
4487
  const backups = this.parseGroup(backupsSchema, draft.backups, "backups");
4215
4488
  this.commit(draft);
4216
4489
  return backups;
@@ -5772,6 +6045,171 @@ var init_ddns$2 = __esmMin((() => {
5772
6045
  BY_ID$1 = new Map(DDNS_PROVIDERS.map((provider) => [provider.id, provider]));
5773
6046
  }));
5774
6047
  //#endregion
6048
+ //#region src/helpers/env-file.ts
6049
+ /** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
6050
+ function parseEnvFile(text) {
6051
+ const env = {};
6052
+ for (const raw of text.split("\n")) {
6053
+ const line = raw.trim();
6054
+ if (line.length === 0 || line.startsWith("#")) continue;
6055
+ const assignment = line.startsWith("export ") ? line.slice(7) : line;
6056
+ const separator = assignment.indexOf("=");
6057
+ if (separator <= 0) continue;
6058
+ const key = assignment.slice(0, separator).trim();
6059
+ let value = assignment.slice(separator + 1).trim();
6060
+ if (value.length > 1 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
6061
+ env[key] = value;
6062
+ }
6063
+ return env;
6064
+ }
6065
+ /** Missing files are not an error: an env file is an optional override layer. */
6066
+ function loadEnvFile(file) {
6067
+ try {
6068
+ return {
6069
+ env: parseEnvFile(fs.readFileSync(file, "utf8")),
6070
+ path: file,
6071
+ error: null
6072
+ };
6073
+ } catch (error) {
6074
+ if (error.code === "ENOENT") return {
6075
+ env: {},
6076
+ path: file,
6077
+ error: null
6078
+ };
6079
+ return {
6080
+ env: {},
6081
+ path: file,
6082
+ error: errorMessage(error)
6083
+ };
6084
+ }
6085
+ }
6086
+ /** Expands `${VAR}` from the given vars; unknown references are left visible. */
6087
+ function expandEnv(value, vars) {
6088
+ return value.replace(VARIABLE, (match, name) => vars[name] ?? match);
6089
+ }
6090
+ function expandEnvRecord(record, vars) {
6091
+ return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, expandEnv(value, vars)]));
6092
+ }
6093
+ function expandEnvList(values, vars) {
6094
+ return values.map((value) => expandEnv(value, vars));
6095
+ }
6096
+ function resolveEnvFilePath(file, cwd) {
6097
+ if (path.isAbsolute(file)) return file;
6098
+ return path.resolve(cwd, file);
6099
+ }
6100
+ var VARIABLE;
6101
+ var init_env_file = __esmMin((() => {
6102
+ init_error_message();
6103
+ VARIABLE = /\$\{([A-Z_]\w*)\}/gi;
6104
+ }));
6105
+ //#endregion
6106
+ //#region src/providers/webhook.ts
6107
+ /** The JSON body for one event. Exported so a test can pin the shape without a round trip. */
6108
+ function webhookPayload(event, now = Date.now()) {
6109
+ const text = `${event.title}\n${event.message}`;
6110
+ return {
6111
+ text,
6112
+ content: text,
6113
+ title: event.title,
6114
+ message: event.message,
6115
+ reason: event.reason,
6116
+ server: event.serverId.length > 0 ? event.serverId : null,
6117
+ label: event.label.length > 0 ? event.label : null,
6118
+ at: now
6119
+ };
6120
+ }
6121
+ /**
6122
+ * `Name: value` lines into a header map.
6123
+ *
6124
+ * The format is line-based rather than a JSON object because it is edited in a textarea, and
6125
+ * a value with a colon in it (`Authorization: Bearer a:b`) must survive: only the **first**
6126
+ * colon separates the two.
6127
+ *
6128
+ * `vars` expands `${ENV}` in the values, so a credential does not have to be written into the
6129
+ * config file. A name the panel's environment does not define is left as written, the same rule
6130
+ * `expandEnv` follows everywhere else.
6131
+ */
6132
+ function parseHeaders(text, vars = process.env) {
6133
+ const headers = {};
6134
+ const errors = [];
6135
+ for (const line of text.split("\n")) {
6136
+ const trimmed = line.trim();
6137
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
6138
+ const at = trimmed.indexOf(":");
6139
+ if (at <= 0) {
6140
+ errors.push(`"${trimmed}" is not a "Name: value" header`);
6141
+ continue;
6142
+ }
6143
+ headers[trimmed.slice(0, at).trim()] = expandEnv(trimmed.slice(at + 1).trim(), vars);
6144
+ }
6145
+ return {
6146
+ headers,
6147
+ errors
6148
+ };
6149
+ }
6150
+ /** Why a target cannot be sent to, or null when it can. Also used to validate a save. */
6151
+ function targetProblem(target, options = {}) {
6152
+ const url = target.url.trim();
6153
+ if (url.length === 0) return options.requireUrl === false ? null : "no URL configured";
6154
+ let parsed;
6155
+ try {
6156
+ parsed = new URL(url);
6157
+ } catch {
6158
+ return "the URL is not valid";
6159
+ }
6160
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return `the URL must be http or https, not ${parsed.protocol}`;
6161
+ const { errors } = parseHeaders(target.headers);
6162
+ return errors.length > 0 ? errors.join("; ") : null;
6163
+ }
6164
+ async function sendWebhook(target, event, fetchImpl) {
6165
+ const problem = targetProblem(target);
6166
+ if (problem !== null) return {
6167
+ ok: false,
6168
+ error: problem
6169
+ };
6170
+ const { headers, errors } = parseHeaders(target.headers);
6171
+ if (errors.length > 0) return {
6172
+ ok: false,
6173
+ error: errors.join("; ")
6174
+ };
6175
+ const payload = webhookPayload(event);
6176
+ const asText = target.format === "text";
6177
+ const body = asText ? payload.message : JSON.stringify(payload);
6178
+ const url = expandEnv(target.url.trim(), process.env);
6179
+ if (url.includes("${")) return {
6180
+ ok: false,
6181
+ error: `the URL still holds an unexpanded placeholder: ${url}`
6182
+ };
6183
+ try {
6184
+ const response = await (fetchImpl ?? fetch)(url, {
6185
+ method: target.method,
6186
+ headers: {
6187
+ "content-type": asText ? "text/plain; charset=utf-8" : "application/json",
6188
+ ...headers
6189
+ },
6190
+ body,
6191
+ signal: AbortSignal.timeout(1e4)
6192
+ });
6193
+ if (!response.ok) {
6194
+ const trimmed = (await response.text().catch(() => "")).trim().slice(0, 200);
6195
+ return {
6196
+ ok: false,
6197
+ error: `answered ${response.status}${trimmed.length > 0 ? `: ${trimmed}` : ""}`
6198
+ };
6199
+ }
6200
+ return { ok: true };
6201
+ } catch (error) {
6202
+ return {
6203
+ ok: false,
6204
+ error: errorMessage(error)
6205
+ };
6206
+ }
6207
+ }
6208
+ var init_webhook = __esmMin((() => {
6209
+ init_env_file();
6210
+ init_error_message();
6211
+ }));
6212
+ //#endregion
5775
6213
  //#region src/config/store.ts
5776
6214
  function readFile(file) {
5777
6215
  let text;
@@ -5817,6 +6255,8 @@ var init_store = __esmMin((() => {
5817
6255
  init_settings$1();
5818
6256
  init_atomic();
5819
6257
  init_ddns$2();
6258
+ init_webhook();
6259
+ init_contracts();
5820
6260
  WorkspaceStore = class {
5821
6261
  workspaceId;
5822
6262
  settingsFile;
@@ -6036,6 +6476,13 @@ var init_store = __esmMin((() => {
6036
6476
  draft.notifications = { ...draft.notifications ?? {} };
6037
6477
  applyPatch(draft.notifications, patch, NOTIFICATION_MERGE_KEYS);
6038
6478
  const notifications = this.parseGroup(notificationsSchema, draft.notifications, "notifications");
6479
+ const duplicates = duplicateWebhookIds(notifications.destinations);
6480
+ if (duplicates.length > 0) throw new ConfigError(`notifications: duplicate webhook id ${duplicates.map((id) => `"${id}"`).join(", ")}`);
6481
+ for (const target of notifications.destinations) {
6482
+ if (target.kind !== "webhook") continue;
6483
+ const problem = targetProblem(target, { requireUrl: true });
6484
+ if (problem !== null) throw new ConfigError(`notifications: webhook "${target.id}" ${problem}`);
6485
+ }
6039
6486
  this.commitSettings(draft);
6040
6487
  return notifications;
6041
6488
  }
@@ -6423,8 +6870,8 @@ var init_events = __esmMin((() => {
6423
6870
  /**
6424
6871
  * Liveness for external monitors. Mounted outside `/api`, so it answers without a
6425
6872
  * session: it reports whether the panel itself is serving, and 503 when an
6426
- * autostart server has crashed. Server details are only included for an
6427
- * authenticated caller.
6873
+ * autostart server has crashed, or a running one is failing its own health check.
6874
+ * Server details are only included for an authenticated caller.
6428
6875
  */
6429
6876
  function createHealthRoute(deps) {
6430
6877
  return appFactory.createApp().get("/healthz", describeRoute({
@@ -6435,12 +6882,12 @@ function createHealthRoute(deps) {
6435
6882
  description: "Serving",
6436
6883
  content: jsonBody(healthResponseSchema)
6437
6884
  },
6438
- 503: { description: "An autostart server has crashed" }
6885
+ 503: { description: "An autostart server has crashed, or a running one is unhealthy" }
6439
6886
  }
6440
6887
  }), (c) => {
6441
6888
  const state = deps.panel.getState();
6442
6889
  const servers = state.workspaces.flatMap((workspace) => workspace.servers);
6443
- const broken = servers.filter((server) => server.config.autostart && server.status === "crashed");
6890
+ const broken = servers.filter((server) => server.config.autostart && server.status === "crashed" || server.status === "running" && server.health === "unhealthy");
6444
6891
  const authenticated = requestIdentity(c, deps.auth).authenticated;
6445
6892
  return c.json({
6446
6893
  status: broken.length > 0 ? "degraded" : "ok",
@@ -6735,58 +7182,77 @@ var init_telegram = __esmMin((() => {
6735
7182
  //#endregion
6736
7183
  //#region src/api/notifications.ts
6737
7184
  /**
6738
- * The bot token is written straight to a workspace's secrets file and never into a
7185
+ * A destination's credential is written straight to a workspace's secrets file and never into a
6739
7186
  * servers config, so notification *policy* and the *credential* stay separate.
6740
7187
  *
6741
- * Notifications belong to a workspace: each one keeps its own token and chat.
7188
+ * Notifications belong to a workspace, and every route is scoped to one. The token is keyed by the
7189
+ * **destination id**, which is what lets a workspace point at more than one Telegram chat.
6742
7190
  */
6743
7191
  function createNotificationsRoute(deps) {
6744
- return appFactory.createApp().put("/notifications/token", describeRoute({
7192
+ const runtimeFor = (workspace) => requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), workspace);
7193
+ return appFactory.createApp().put("/notifications/destinations/:id/token", describeRoute({
6745
7194
  tags: ["notifications"],
6746
- summary: "Store the Telegram bot token (verified first)",
7195
+ summary: "Store a Telegram destination's bot token (verified first)",
6747
7196
  responses: {
6748
7197
  200: { description: "Stored" },
6749
7198
  400: ERROR_RESPONSES[400]
6750
7199
  }
6751
- }), validate("query", workspaceQuerySchema), validate("json", telegramTokenSchema), async (c) => {
6752
- const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
7200
+ }), validate("query", workspaceQuerySchema), validate("param", destinationIdParamSchema), validate("json", telegramTokenSchema), async (c) => {
7201
+ const runtime = runtimeFor(c.req.valid("query").workspace);
7202
+ const id = c.req.valid("param").id;
6753
7203
  const { botToken } = c.req.valid("json");
6754
7204
  const verified = await runtime.notifications.verifyToken(botToken);
6755
7205
  if (!verified.ok) throw new DetailedError(`telegram rejected the token: ${verified.error ?? "unknown error"}`, {
6756
7206
  statusCode: 400,
6757
7207
  code: "TELEGRAM_TOKEN_REJECTED"
6758
7208
  });
6759
- runtime.secrets.setTelegramToken(botToken);
7209
+ runtime.secrets.setTelegramToken(id, botToken);
6760
7210
  forgetBots();
6761
7211
  return c.json({
6762
7212
  ok: true,
6763
7213
  username: verified.username ?? null
6764
7214
  });
6765
- }).delete("/notifications/token", describeRoute({
7215
+ }).delete("/notifications/destinations/:id/token", describeRoute({
6766
7216
  tags: ["notifications"],
6767
- summary: "Forget the Telegram bot token",
7217
+ summary: "Forget a Telegram destination's bot token",
6768
7218
  responses: { 200: { description: "Removed" } }
6769
- }), validate("query", workspaceQuerySchema), (c) => {
6770
- requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).secrets.setTelegramToken(null);
7219
+ }), validate("query", workspaceQuerySchema), validate("param", destinationIdParamSchema), (c) => {
7220
+ runtimeFor(c.req.valid("query").workspace).secrets.setTelegramToken(c.req.valid("param").id, null);
6771
7221
  forgetBots();
6772
7222
  return c.json({ ok: true });
6773
- }).post("/notifications/test", describeRoute({
7223
+ }).post("/notifications/destinations/:id/test", describeRoute({
6774
7224
  tags: ["notifications"],
6775
- summary: "Send a test message",
7225
+ summary: "Send a test message to one destination",
6776
7226
  responses: {
6777
7227
  200: { description: "Sent or refused" },
6778
7228
  400: ERROR_RESPONSES[400]
6779
7229
  }
6780
- }), validate("query", workspaceQuerySchema), validate("json", notificationActionSchema), async (c) => {
6781
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).notifications.sendTest(c.req.valid("json"));
7230
+ }), validate("query", workspaceQuerySchema), validate("param", destinationIdParamSchema), validate("json", notificationActionSchema), async (c) => {
7231
+ const result = await runtimeFor(c.req.valid("query").workspace).notifications.sendTest(c.req.valid("param").id, c.req.valid("json"));
6782
7232
  if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
6783
7233
  statusCode: 400,
6784
7234
  code: "TELEGRAM_SEND_FAILED"
6785
7235
  });
6786
7236
  return c.json(result);
6787
- }).post("/notifications/detect-chats", describeRoute({
7237
+ }).post("/notifications/test-webhook", describeRoute({
7238
+ tags: ["notifications"],
7239
+ summary: "Send a test to one webhook destination",
7240
+ responses: {
7241
+ 200: { description: "Sent or refused" },
7242
+ 400: ERROR_RESPONSES[400]
7243
+ }
7244
+ }), validate("query", workspaceQuerySchema), validate("json", destinationTestSchema), async (c) => {
7245
+ const runtime = runtimeFor(c.req.valid("query").workspace);
7246
+ const { target, id } = c.req.valid("json");
7247
+ const result = await runtime.notifications.testWebhook(target, id);
7248
+ if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
7249
+ statusCode: 400,
7250
+ code: "WEBHOOK_SEND_FAILED"
7251
+ });
7252
+ return c.json(result);
7253
+ }).post("/notifications/destinations/:id/detect-chats", describeRoute({
6788
7254
  tags: ["notifications"],
6789
- summary: "List the chats the bot can see",
7255
+ summary: "List the chats a Telegram destination's bot can see",
6790
7256
  responses: {
6791
7257
  200: {
6792
7258
  description: "Chats",
@@ -6794,8 +7260,8 @@ function createNotificationsRoute(deps) {
6794
7260
  },
6795
7261
  400: ERROR_RESPONSES[400]
6796
7262
  }
6797
- }), validate("query", workspaceQuerySchema), validate("json", notificationActionSchema), async (c) => {
6798
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).notifications.detectChats(c.req.valid("json"));
7263
+ }), validate("query", workspaceQuerySchema), validate("param", destinationIdParamSchema), validate("json", notificationActionSchema), async (c) => {
7264
+ const result = await runtimeFor(c.req.valid("query").workspace).notifications.detectChats(c.req.valid("param").id, c.req.valid("json"));
6799
7265
  if (!result.ok) throw new DetailedError(result.error ?? "could not list chats", {
6800
7266
  statusCode: 400,
6801
7267
  code: "TELEGRAM_LIST_FAILED"
@@ -10361,7 +10827,7 @@ function createServersRoute(deps) {
10361
10827
  404: ERROR_RESPONSES[404]
10362
10828
  }
10363
10829
  }), validate("query", workspaceQuerySchema), validate("param", idParam$1), async (c) => {
10364
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.valid("param").id);
10830
+ const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.valid("param").id, { actor: requestActor(c, deps.auth) });
10365
10831
  return c.json(result, statusForAction(result));
10366
10832
  }).post("/:id/stop", describeRoute({
10367
10833
  tags: ["servers"],
@@ -10381,7 +10847,7 @@ function createServersRoute(deps) {
10381
10847
  404: ERROR_RESPONSES[404]
10382
10848
  }
10383
10849
  }), validate("query", workspaceQuerySchema), validate("param", idParam$1), async (c) => {
10384
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.valid("param").id);
10850
+ const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.valid("param").id, { actor: requestActor(c, deps.auth) });
10385
10851
  return c.json(result, statusForAction(result));
10386
10852
  }).post("/:id/clear-logs", describeRoute({
10387
10853
  tags: ["servers"],
@@ -10469,6 +10935,7 @@ var init_$_routes$1 = __esmMin((() => {
10469
10935
  init_openapi$1();
10470
10936
  init_validator();
10471
10937
  init_workspace();
10938
+ init_auth();
10472
10939
  init_events$1();
10473
10940
  init_contracts();
10474
10941
  idParam$1 = type({ id: "string >= 1" });
@@ -10496,7 +10963,10 @@ function buildWorkspaceSettings(runtime) {
10496
10963
  configError: runtime.store.configError,
10497
10964
  defaults: runtime.store.defaults,
10498
10965
  logs: runtime.store.logs,
10499
- notifications: { telegram: runtime.notifications.status() }
10966
+ notifications: {
10967
+ defaults: runtime.notifications.defaults(),
10968
+ destinations: runtime.notifications.destinations()
10969
+ }
10500
10970
  };
10501
10971
  }
10502
10972
  function createSettingsRoute(deps) {
@@ -10688,7 +11158,10 @@ function createSettingsRoute(deps) {
10688
11158
  try {
10689
11159
  if (patch.defaults !== void 0) runtime.store.updateDefaults(patch.defaults);
10690
11160
  if (patch.logs !== void 0) runtime.store.updateLogs(patch.logs);
10691
- if (patch.notifications !== void 0) runtime.store.updateNotifications(patch.notifications);
11161
+ if (patch.notifications !== void 0) {
11162
+ const saved = runtime.store.updateNotifications(patch.notifications);
11163
+ runtime.secrets.forgetTelegramTokens(saved.destinations.map((destination) => destination.id));
11164
+ }
10692
11165
  } catch (error) {
10693
11166
  if (error instanceof ConfigError) throw new DetailedError(error.message, {
10694
11167
  statusCode: 400,
@@ -11427,62 +11900,88 @@ var init_acme_challenge = __esmMin((() => {
11427
11900
  };
11428
11901
  }));
11429
11902
  //#endregion
11430
- //#region src/helpers/env-file.ts
11431
- /** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
11432
- function parseEnvFile(text) {
11433
- const env = {};
11434
- for (const raw of text.split("\n")) {
11435
- const line = raw.trim();
11436
- if (line.length === 0 || line.startsWith("#")) continue;
11437
- const assignment = line.startsWith("export ") ? line.slice(7) : line;
11438
- const separator = assignment.indexOf("=");
11439
- if (separator <= 0) continue;
11440
- const key = assignment.slice(0, separator).trim();
11441
- let value = assignment.slice(separator + 1).trim();
11442
- if (value.length > 1 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
11443
- env[key] = value;
11903
+ //#region src/config/secret-parts.ts
11904
+ /**
11905
+ * The parts of one scope, in a stable order: the known parts first in the fixed order
11906
+ * password, apiToken (global) / telegram, ddns (workspace) — then any other top-level
11907
+ * key present in `present`, sorted, as its own part with `fields: [key]`.
11908
+ *
11909
+ * An unknown key is taken as belonging to the scope being asked about, because the file
11910
+ * it was read from is the only evidence of where it lives: this release does not know the
11911
+ * key, so classifying it by name would drop a future *global* secret on the floor.
11912
+ */
11913
+ function secretParts(scope, present) {
11914
+ const keys = new Set(present);
11915
+ const known = KNOWN[scope];
11916
+ const parts = known.filter((key) => keys.has(key)).map((key) => ({
11917
+ key,
11918
+ fields: EXTRA_FIELDS[key] ?? [key],
11919
+ scope
11920
+ }));
11921
+ const consumed = new Set(parts.flatMap((part) => part.fields));
11922
+ const rest = [...keys].filter((key) => KNOWN_SCOPES[key] === void 0 && !known.includes(key) && !consumed.has(key)).sort();
11923
+ return [...parts, ...rest.map((key) => ({
11924
+ key,
11925
+ fields: [key],
11926
+ scope
11927
+ }))];
11928
+ }
11929
+ /** The top-level keys of a raw secrets document that hold a non-null value. */
11930
+ function presentSecretKeys(source) {
11931
+ return Object.entries(source).filter(([, value]) => value !== null && value !== void 0).map(([key]) => key);
11932
+ }
11933
+ /**
11934
+ * A JSON string carrying only `fields`, taken from `source`. Returns null when `source`
11935
+ * carries none of them. Always includes a field whose value is `null` ONLY if it is
11936
+ * present as a key and non-null; a null/absent field is omitted.
11937
+ */
11938
+ function pickSecrets(source, fields) {
11939
+ const picked = {};
11940
+ for (const field of fields) {
11941
+ const value = source[field];
11942
+ if (value !== null && value !== void 0) picked[field] = value;
11444
11943
  }
11445
- return env;
11944
+ return Object.keys(picked).length === 0 ? null : JSON.stringify(picked);
11446
11945
  }
11447
- /** Missing files are not an error: an env file is an optional override layer. */
11448
- function loadEnvFile(file) {
11946
+ /** Parses a secrets document, tolerating anything: not-an-object, or broken JSON returns {}. */
11947
+ function parseSecrets(text) {
11449
11948
  try {
11450
- return {
11451
- env: parseEnvFile(fs.readFileSync(file, "utf8")),
11452
- path: file,
11453
- error: null
11454
- };
11455
- } catch (error) {
11456
- if (error.code === "ENOENT") return {
11457
- env: {},
11458
- path: file,
11459
- error: null
11460
- };
11461
- return {
11462
- env: {},
11463
- path: file,
11464
- error: errorMessage(error)
11465
- };
11949
+ const parsed = JSON.parse(text);
11950
+ return isRecord(parsed) ? parsed : {};
11951
+ } catch {
11952
+ return {};
11466
11953
  }
11467
11954
  }
11468
- /** Expands `${VAR}` from the given vars; unknown references are left visible. */
11469
- function expandEnv(value, vars) {
11470
- return value.replace(VARIABLE, (match, name) => vars[name] ?? match);
11471
- }
11472
- function expandEnvRecord(record, vars) {
11473
- return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, expandEnv(value, vars)]));
11474
- }
11475
- function expandEnvList(values, vars) {
11476
- return values.map((value) => expandEnv(value, vars));
11477
- }
11478
- function resolveEnvFilePath(file, cwd) {
11479
- if (path.isAbsolute(file)) return file;
11480
- return path.resolve(cwd, file);
11955
+ /**
11956
+ * Merges `piece` into `current`, changing ONLY the fields in `fields` — every other key
11957
+ * of `current` is preserved (this is what stops restoring the Telegram token from wiping
11958
+ * the DDNS credentials). A field that is absent from `piece` is also DELETED from the
11959
+ * result, so restoring an older archive that predates a secret clears it rather than
11960
+ * leaving a stale value. Returns a new object; does not mutate its arguments.
11961
+ */
11962
+ function mergeSecrets(current, piece, fields) {
11963
+ const merged = { ...current };
11964
+ for (const field of fields) {
11965
+ const value = piece[field];
11966
+ if (value === null || value === void 0) delete merged[field];
11967
+ else merged[field] = value;
11968
+ }
11969
+ return merged;
11481
11970
  }
11482
- var VARIABLE;
11483
- var init_env_file = __esmMin((() => {
11484
- init_error_message();
11485
- VARIABLE = /\$\{([A-Z_]\w*)\}/gi;
11971
+ var KNOWN, KNOWN_SCOPES, EXTRA_FIELDS;
11972
+ var init_secret_parts = __esmMin((() => {
11973
+ init_shape();
11974
+ KNOWN = {
11975
+ global: ["password", "apiToken"],
11976
+ workspace: ["telegram", "ddns"]
11977
+ };
11978
+ KNOWN_SCOPES = {
11979
+ password: "global",
11980
+ apiToken: "global",
11981
+ telegram: "workspace",
11982
+ ddns: "workspace"
11983
+ };
11984
+ EXTRA_FIELDS = { ddns: ["ddns", "ddnsKdf"] };
11486
11985
  }));
11487
11986
  //#endregion
11488
11987
  //#region src/providers/archive.ts
@@ -11684,6 +12183,41 @@ function computeBackoff(attempt, options) {
11684
12183
  }
11685
12184
  var init_backoff = __esmMin((() => {}));
11686
12185
  //#endregion
12186
+ //#region src/providers/entry-state.ts
12187
+ /**
12188
+ * The small slice of a server's runtime the panel has to remember across its own
12189
+ * restarts: how much of the retry budget is spent, and whether a `runOnce` bootstrap
12190
+ * already ran.
12191
+ *
12192
+ * Both lived only in memory, so a panel restart — a `restart`, a crash, a reboot —
12193
+ * handed a crash-looping server a fresh `maxRetries` and re-ran its bootstrap. The
12194
+ * retry counter has to outlive the process that increments it to bound anything.
12195
+ */
12196
+ /** One JSON file per entry, beside the nanny's own state. */
12197
+ function entryStatePath(dir, id) {
12198
+ return path.join(dir, `${id}.run.json`);
12199
+ }
12200
+ function readEntryState(file) {
12201
+ try {
12202
+ const parsed = entryStateSchema(JSON.parse(fs.readFileSync(file, "utf8")));
12203
+ return parsed instanceof type.errors ? null : parsed;
12204
+ } catch {
12205
+ return null;
12206
+ }
12207
+ }
12208
+ function writeEntryState(file, state) {
12209
+ try {
12210
+ writeFileAtomic(file, `${JSON.stringify(state)}\n`);
12211
+ } catch {}
12212
+ }
12213
+ function clearEntryState(file) {
12214
+ fs.rmSync(file, { force: true });
12215
+ }
12216
+ var init_entry_state = __esmMin((() => {
12217
+ init_atomic();
12218
+ init_contracts();
12219
+ }));
12220
+ //#endregion
11687
12221
  //#region src/providers/health-check.ts
11688
12222
  /** TCP connect timing, used for the default `port` mode and readiness. */
11689
12223
  async function probeTcp(host, port, timeoutMs) {
@@ -11696,18 +12230,99 @@ async function probeTcp(host, port, timeoutMs) {
11696
12230
  };
11697
12231
  }
11698
12232
  /**
12233
+ * One request/response, capped at `timeoutMs` and `MAX_BODY_BYTES`.
12234
+ *
12235
+ * Node's `fetch` cannot be told to accept a certificate it does not trust, which is the normal
12236
+ * case for a backend on a home network: its own self-signed pair, or one from the engine's local
12237
+ * CA. So this speaks http/https directly, the same way `helpers/daemon.ts` reaches a panel with an
12238
+ * uploaded pair. A `HEAD` gets no body, which the caller already accounts for.
12239
+ */
12240
+ function request(url, method, allowInvalidCert, timeoutMs) {
12241
+ return new Promise((resolve, reject) => {
12242
+ const secure = url.protocol === "https:";
12243
+ let req;
12244
+ let settled = false;
12245
+ let deadline;
12246
+ const finish = (value) => {
12247
+ if (settled) return;
12248
+ settled = true;
12249
+ clearTimeout(deadline);
12250
+ resolve(value);
12251
+ };
12252
+ const fail = (error) => {
12253
+ if (settled) return;
12254
+ settled = true;
12255
+ clearTimeout(deadline);
12256
+ reject(error instanceof Error ? error : new Error(String(error)));
12257
+ };
12258
+ /**
12259
+ * The whole exchange, not just the socket.
12260
+ *
12261
+ * `timeout` on the request is an **idle** timeout, so a server dripping one byte every half
12262
+ * second keeps resetting it and the probe never returns — which hangs the supervisor's tick,
12263
+ * since the probe is awaited. The old `fetch` path had `AbortSignal.timeout`, an absolute
12264
+ * cap; this restores that guarantee.
12265
+ */
12266
+ deadline = setTimeout(() => {
12267
+ fail(/* @__PURE__ */ new Error(`no complete response within ${timeoutMs}ms`));
12268
+ req.destroy();
12269
+ }, timeoutMs);
12270
+ deadline.unref();
12271
+ req = (secure ? https : http).request({
12272
+ hostname: url.hostname,
12273
+ port: url.port,
12274
+ path: `${url.pathname}${url.search}`,
12275
+ method,
12276
+ ...secure && allowInvalidCert ? { rejectUnauthorized: false } : {},
12277
+ timeout: timeoutMs
12278
+ }, (response) => {
12279
+ let body = "";
12280
+ let bytes = 0;
12281
+ let truncated = false;
12282
+ response.setEncoding("utf8");
12283
+ response.on("data", (chunk) => {
12284
+ if (truncated) return;
12285
+ bytes += Buffer$1.byteLength(chunk);
12286
+ if (bytes > MAX_BODY_BYTES) {
12287
+ truncated = true;
12288
+ body += chunk.slice(0, Math.max(0, MAX_BODY_BYTES - (bytes - Buffer$1.byteLength(chunk))));
12289
+ response.destroy();
12290
+ finish({
12291
+ status: response.statusCode ?? 0,
12292
+ body,
12293
+ truncated
12294
+ });
12295
+ return;
12296
+ }
12297
+ body += chunk;
12298
+ });
12299
+ response.once("end", () => {
12300
+ if (!truncated) finish({
12301
+ status: response.statusCode ?? 0,
12302
+ body,
12303
+ truncated
12304
+ });
12305
+ });
12306
+ response.once("error", (error) => {
12307
+ if (!truncated) fail(error);
12308
+ });
12309
+ });
12310
+ req.once("error", (error) => fail(error));
12311
+ req.once("timeout", () => {
12312
+ fail(/* @__PURE__ */ new Error(`no response within ${timeoutMs}ms`));
12313
+ req.destroy();
12314
+ });
12315
+ req.end();
12316
+ });
12317
+ }
12318
+ /**
11699
12319
  * Fetches the configured path and asserts the response, so "listening" is not
11700
12320
  * mistaken for "working".
11701
12321
  */
11702
12322
  async function probeHttp(host, port, options) {
11703
- const url = `http://${host}:${port}${options.path.startsWith("/") ? options.path : `/${options.path}`}`;
11704
12323
  const started = Date.now();
11705
12324
  try {
11706
- const response = await fetch(url, {
11707
- method: options.method,
11708
- redirect: "manual",
11709
- signal: AbortSignal.timeout(options.timeoutMs)
11710
- });
12325
+ const response = await request(new URL(`${options.scheme}://${host}:${port}${options.path.startsWith("/") ? options.path : `/${options.path}`}`), options.method, options.allowInvalidCert, options.timeoutMs);
11711
12326
  const ms = Date.now() - started;
11712
12327
  const expected = options.expectStatus ?? null;
11713
12328
  if (expected !== null && response.status !== expected) return {
@@ -11722,11 +12337,14 @@ async function probeHttp(host, port, options) {
11722
12337
  };
11723
12338
  const bodySkipped = options.expectBody.length > 0 && options.method === "HEAD";
11724
12339
  if (options.expectBody.length > 0 && !bodySkipped) {
11725
- if (!(await response.text()).includes(options.expectBody)) return {
11726
- healthy: false,
11727
- ms,
11728
- detail: `body does not contain ${JSON.stringify(options.expectBody)}`
11729
- };
12340
+ if (!response.body.includes(options.expectBody)) {
12341
+ const scope = response.truncated ? ` (only the first ${MAX_BODY_BYTES} bytes were read)` : "";
12342
+ return {
12343
+ healthy: false,
12344
+ ms,
12345
+ detail: `body does not contain ${JSON.stringify(options.expectBody)}${scope}`
12346
+ };
12347
+ }
11730
12348
  }
11731
12349
  if (bodySkipped) return {
11732
12350
  healthy: true,
@@ -11762,9 +12380,11 @@ async function probeHealth(options) {
11762
12380
  }
11763
12381
  return last;
11764
12382
  }
12383
+ var MAX_BODY_BYTES;
11765
12384
  var init_health_check = __esmMin((() => {
11766
12385
  init_port();
11767
12386
  init_error_message();
12387
+ MAX_BODY_BYTES = 1e6;
11768
12388
  }));
11769
12389
  //#endregion
11770
12390
  //#region src/providers/process.ts
@@ -12304,6 +12924,7 @@ var init_supervisor = __esmMin((() => {
12304
12924
  init_paths();
12305
12925
  init_runtime();
12306
12926
  init_template();
12927
+ init_entry_state();
12307
12928
  init_health_check();
12308
12929
  init_identity();
12309
12930
  init_nanny$2();
@@ -12371,7 +12992,7 @@ var init_supervisor = __esmMin((() => {
12371
12992
  }
12372
12993
  }
12373
12994
  const targets = [...this.entries.values()].filter((entry) => !options.autostartOnly || entry.config.autostart).map((entry) => entry.config);
12374
- for (const config of orderByDependencies(targets)) await this.start(config.id);
12995
+ for (const config of orderByDependencies(targets)) await this.start(config.id, { actor: "boot" });
12375
12996
  }
12376
12997
  /**
12377
12998
  * Stops every entry this command owns. A persistent entry is deliberately not among
@@ -12402,11 +13023,13 @@ var init_supervisor = __esmMin((() => {
12402
13023
  error: `server "${id}" is stopping`
12403
13024
  };
12404
13025
  entry.starting = true;
13026
+ entry.actor = options.retry ? "policy" : options.actor ?? "policy";
12405
13027
  this.clearRetry(entry);
12406
13028
  if (!options.retry) {
12407
13029
  entry.restarts = 0;
12408
13030
  entry.healthFailures = 0;
12409
13031
  entry.unhealthySince = null;
13032
+ this.persistEntryState(entry);
12410
13033
  }
12411
13034
  try {
12412
13035
  await this.startDependencies(entry);
@@ -12419,6 +13042,14 @@ var init_supervisor = __esmMin((() => {
12419
13042
  entry.health = entry.config.health.enabled ? "unknown" : "disabled";
12420
13043
  this.publishServer(entry);
12421
13044
  if (entry.config.persistent && await this.resumePersistent(entry)) return { ok: true };
13045
+ if (!entry.config.persistent) {
13046
+ const recorded = await this.resolveRecordedChild(entry);
13047
+ if (recorded.kind === "blocked") return {
13048
+ ok: false,
13049
+ error: recorded.error
13050
+ };
13051
+ if (recorded.kind === "adopt") return this.adoptEntry(entry, recorded.pid, "the child this panel started before it died is still running");
13052
+ }
12422
13053
  await this.runBootstrap(entry);
12423
13054
  if (entry.stopping) return {
12424
13055
  ok: false,
@@ -12433,7 +13064,7 @@ var init_supervisor = __esmMin((() => {
12433
13064
  ok: false,
12434
13065
  error: conflict.error
12435
13066
  };
12436
- if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid);
13067
+ if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid, `a detached restart of this entry is already serving port ${entry.config.port}`);
12437
13068
  return this.spawnEntry(entry);
12438
13069
  } finally {
12439
13070
  entry.starting = false;
@@ -12444,9 +13075,9 @@ var init_supervisor = __esmMin((() => {
12444
13075
  if (!entry) return missingServer(id);
12445
13076
  return this.stopEntry(entry);
12446
13077
  }
12447
- async restart(id) {
13078
+ async restart(id, options = {}) {
12448
13079
  await this.stop(id);
12449
- return this.start(id);
13080
+ return this.start(id, options);
12450
13081
  }
12451
13082
  /**
12452
13083
  * Frees the port this entry wants, by asking whatever listens on it to leave.
@@ -12678,6 +13309,10 @@ var init_supervisor = __esmMin((() => {
12678
13309
  entry.pid = null;
12679
13310
  entry.adopted = false;
12680
13311
  entry.nannyExit = null;
13312
+ if (entry.lastChild !== null && !isProcessAlive(entry.lastChild.pid)) {
13313
+ entry.lastChild = null;
13314
+ this.persistEntryState(entry);
13315
+ }
12681
13316
  entry.status = survivor === null ? "stopped" : "conflict";
12682
13317
  if (survivor !== null) entry.lastError = `pid ${survivor} survived the stop`;
12683
13318
  this.log(entry, "system", survivor === null ? "stopped" : entry.lastError);
@@ -12688,6 +13323,8 @@ var init_supervisor = __esmMin((() => {
12688
13323
  };
12689
13324
  }
12690
13325
  createEntry(config) {
13326
+ const persisted = readEntryState(entryStatePath(this.options.nannyDir, config.id));
13327
+ const lastPid = persisted?.lastPid ?? null;
12691
13328
  return {
12692
13329
  config,
12693
13330
  status: "stopped",
@@ -12696,9 +13333,13 @@ var init_supervisor = __esmMin((() => {
12696
13333
  child: null,
12697
13334
  pid: null,
12698
13335
  startedAt: null,
13336
+ lastChild: !config.persistent && lastPid !== null ? {
13337
+ pid: lastPid,
13338
+ startedAt: persisted?.lastStartedAt ?? 0
13339
+ } : null,
12699
13340
  exitCode: null,
12700
13341
  exitSignal: null,
12701
- restarts: 0,
13342
+ restarts: persisted?.restarts ?? 0,
12702
13343
  lastError: null,
12703
13344
  nextRetryAt: null,
12704
13345
  retryTimer: null,
@@ -12711,8 +13352,9 @@ var init_supervisor = __esmMin((() => {
12711
13352
  nannyExit: null,
12712
13353
  starting: false,
12713
13354
  stopping: false,
13355
+ actor: "policy",
12714
13356
  removed: false,
12715
- bootstrapDone: !config.bootstrap,
13357
+ bootstrapDone: persisted?.bootstrapDone ?? !config.bootstrap,
12716
13358
  logs: new LogBuffer(config.logBufferLines),
12717
13359
  echoed: /* @__PURE__ */ new Set(),
12718
13360
  responseMs: null,
@@ -12730,6 +13372,7 @@ var init_supervisor = __esmMin((() => {
12730
13372
  this.relay.unfollow(id);
12731
13373
  this.options.logFiles.clear(id);
12732
13374
  this.options.history.forget(id);
13375
+ clearEntryState(entryStatePath(this.options.nannyDir, id));
12733
13376
  entry.removed = true;
12734
13377
  this.stopEntry(entry).catch((error) => {
12735
13378
  logger.error(`could not stop the removed server ${id}`, error);
@@ -12853,16 +13496,6 @@ var init_supervisor = __esmMin((() => {
12853
13496
  return { kind: "free" };
12854
13497
  }
12855
13498
  /**
12856
- * Takes over a detached successor: no spawn, no duplicate. The pid is supervised
12857
- * from here on (liveness, health probe, resources, stop), while its output stays
12858
- * wherever it was redirected.
12859
- *
12860
- * For a persistent entry that output is not a mystery: its nanny is still writing
12861
- * the entry's log file, so the relay is started here too. Without this, adopting a
12862
- * persistent entry whose state file was lost would show its history but never
12863
- * another live line.
12864
- */
12865
- /**
12866
13499
  * Stops the listed port holders and reports whether the port came free.
12867
13500
  *
12868
13501
  * `kill` and `reclaim` are the same sequence over a different pid list — the foreign
@@ -12887,7 +13520,21 @@ var init_supervisor = __esmMin((() => {
12887
13520
  error: entry.lastError
12888
13521
  };
12889
13522
  }
12890
- adoptEntry(entry, pid) {
13523
+ /**
13524
+ * Takes over a detached successor: no spawn, no duplicate. The pid is supervised
13525
+ * from here on (liveness, health probe, resources, stop), while its output stays
13526
+ * wherever it was redirected.
13527
+ *
13528
+ * For a persistent entry that output is not a mystery: its nanny is still writing
13529
+ * the entry's log file, so the relay is started here too. Without this, adopting a
13530
+ * persistent entry whose state file was lost would show its history but never
13531
+ * another live line.
13532
+ *
13533
+ * `reason` is the caller's sentence for *why* this pid is ours: a port holder and a
13534
+ * recorded child that survived a hard panel death are the same adoption, said
13535
+ * differently, and the log line is the only place a person sees which happened.
13536
+ */
13537
+ adoptEntry(entry, pid, reason) {
12891
13538
  entry.adopted = true;
12892
13539
  entry.pid = pid;
12893
13540
  entry.child = null;
@@ -12896,12 +13543,60 @@ var init_supervisor = __esmMin((() => {
12896
13543
  entry.startedAt = Date.now();
12897
13544
  entry.lastError = null;
12898
13545
  if (entry.config.persistent) this.followPersistent(entry, true);
12899
- this.log(entry, "system", `adopted pid ${pid}: a detached restart of this entry is already serving port ${entry.config.port}`);
13546
+ this.log(entry, "system", `adopted pid ${pid}: ${reason}`);
12900
13547
  this.publishServer(entry);
12901
13548
  return { ok: true };
12902
13549
  }
13550
+ /**
13551
+ * The child this entry's state file recorded, when it is still that same process.
13552
+ *
13553
+ * A non-persistent child is spawned detached, so it survives a panel killed hard while
13554
+ * nothing else remembers it — `preflight` cannot help a portless entry, which is how a
13555
+ * portless worker got a second copy beside the running one. A pid is recycled, so
13556
+ * liveness alone proves nothing: the birth time and the marker decide, and a pid this
13557
+ * platform cannot place is never adopted on a guess.
13558
+ *
13559
+ * The record is cleared only once its process is known to be gone or to be somebody
13560
+ * else, so a stale pid is never chased and a live one is never forgotten into a twin.
13561
+ */
13562
+ async resolveRecordedChild(entry) {
13563
+ const recorded = entry.lastChild;
13564
+ if (recorded === null) return { kind: "none" };
13565
+ if (recorded.pid === process.pid) {
13566
+ entry.lastChild = null;
13567
+ this.persistEntryState(entry);
13568
+ return { kind: "none" };
13569
+ }
13570
+ const liveness = recorded.startedAt > 0 ? await processLiveness(recorded.pid, recorded.startedAt) : isProcessAlive(recorded.pid) ? "unknown" : "gone";
13571
+ if (liveness === "gone" || liveness === "recycled") {
13572
+ entry.lastChild = null;
13573
+ this.persistEntryState(entry);
13574
+ return { kind: "none" };
13575
+ }
13576
+ if (await processCarriesServerId(recorded.pid, entry.config.id)) return {
13577
+ kind: "adopt",
13578
+ pid: recorded.pid
13579
+ };
13580
+ if (entry.config.port !== null) return { kind: "none" };
13581
+ const error = `pid ${recorded.pid} may be a child this panel started for "${entry.config.id}" before it died, and this platform cannot confirm whose it is — not starting a second copy, stop pid ${recorded.pid} to clear it`;
13582
+ entry.status = "conflict";
13583
+ entry.lastError = error;
13584
+ this.log(entry, "system", error);
13585
+ this.publishServer(entry);
13586
+ return {
13587
+ kind: "blocked",
13588
+ error
13589
+ };
13590
+ }
13591
+ /** Clears the durable record of the last child, once that pid is known to be gone. */
13592
+ forgetRecordedChild(entry, pid) {
13593
+ if (pid === null || entry.lastChild === null || entry.lastChild.pid !== pid) return;
13594
+ entry.lastChild = null;
13595
+ this.persistEntryState(entry);
13596
+ }
12903
13597
  /** An adopted successor disappeared: fall back to the normal lifecycle. */
12904
13598
  handleAdoptedExit(entry) {
13599
+ const adoptedPid = entry.pid;
12905
13600
  if (entry.pid !== null) this.sampler.forget(entry.pid);
12906
13601
  if (entry.config.persistent) this.consumeNannyExit(entry);
12907
13602
  const ranForMs = entry.nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
@@ -12909,6 +13604,7 @@ var init_supervisor = __esmMin((() => {
12909
13604
  entry.nannyExit = null;
12910
13605
  entry.adopted = false;
12911
13606
  entry.pid = null;
13607
+ this.forgetRecordedChild(entry, adoptedPid);
12912
13608
  entry.resources = null;
12913
13609
  entry.responseMs = null;
12914
13610
  entry.exitCode = null;
@@ -12949,6 +13645,7 @@ var init_supervisor = __esmMin((() => {
12949
13645
  } catch (error) {
12950
13646
  this.log(entry, "system", `bootstrap could not start: ${error.message} — continuing anyway`);
12951
13647
  entry.bootstrapDone = true;
13648
+ this.persistEntryState(entry);
12952
13649
  return;
12953
13650
  }
12954
13651
  child.stdout?.on("data", (chunk) => splitter.push("stdout", chunk));
@@ -12971,6 +13668,7 @@ var init_supervisor = __esmMin((() => {
12971
13668
  });
12972
13669
  });
12973
13670
  entry.bootstrapDone = true;
13671
+ this.persistEntryState(entry);
12974
13672
  if (code === 0) this.log(entry, "system", "bootstrap finished");
12975
13673
  else if (code !== null) this.log(entry, "system", `bootstrap exited with code ${code} — continuing anyway`);
12976
13674
  }
@@ -13112,9 +13810,17 @@ var init_supervisor = __esmMin((() => {
13112
13810
  entry.child = child;
13113
13811
  entry.pid = child.pid ?? null;
13114
13812
  entry.startedAt = Date.now();
13813
+ if (!entry.config.persistent && child.pid !== void 0) {
13814
+ entry.lastChild = {
13815
+ pid: child.pid,
13816
+ startedAt: entry.startedAt
13817
+ };
13818
+ this.persistEntryState(entry);
13819
+ }
13115
13820
  this.options.history.record(entry.config.id, {
13116
13821
  type: "start",
13117
- detail
13822
+ detail,
13823
+ actor: entry.actor
13118
13824
  });
13119
13825
  entry.exitCode = null;
13120
13826
  entry.exitSignal = null;
@@ -13304,6 +14010,7 @@ var init_supervisor = __esmMin((() => {
13304
14010
  entry.responseMs = null;
13305
14011
  entry.exitCode = code;
13306
14012
  entry.exitSignal = signal;
14013
+ this.forgetRecordedChild(entry, child.pid ?? null);
13307
14014
  const neverStarted = nannyExit !== null ? nannyExit.code === null && nannyExit.signal === null : code === null && signal === null && entry.lastError !== null;
13308
14015
  const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : describeNannyExit({
13309
14016
  code,
@@ -13332,10 +14039,12 @@ var init_supervisor = __esmMin((() => {
13332
14039
  }
13333
14040
  const restart = entry.config.restart;
13334
14041
  if (ranForMs >= restart.resetAfterMs) entry.restarts = 0;
14042
+ this.persistEntryState(entry);
13335
14043
  this.log(entry, "system", neverStarted ? `did not start: ${detail}` : `exited with ${detail} after ${ranFor}`);
13336
14044
  entry.lastError = neverStarted ? detail : `exited with ${detail}`;
13337
14045
  if (restart.enabled && entry.restarts < restart.maxRetries) {
13338
14046
  entry.restarts += 1;
14047
+ this.persistEntryState(entry);
13339
14048
  const backoffMs = computeBackoff(entry.restarts, restart);
13340
14049
  entry.status = "backoff";
13341
14050
  entry.nextRetryAt = Date.now() + backoffMs;
@@ -13471,6 +14180,16 @@ var init_supervisor = __esmMin((() => {
13471
14180
  entry.retryTimer = null;
13472
14181
  }
13473
14182
  }
14183
+ /** The decisions that have to survive this panel, and nothing else. */
14184
+ persistEntryState(entry) {
14185
+ if (entry.removed) return;
14186
+ writeEntryState(entryStatePath(this.options.nannyDir, entry.config.id), {
14187
+ restarts: entry.restarts,
14188
+ bootstrapDone: entry.bootstrapDone,
14189
+ lastPid: entry.config.persistent ? null : entry.lastChild?.pid ?? null,
14190
+ lastStartedAt: entry.config.persistent ? null : entry.lastChild?.startedAt ?? null
14191
+ });
14192
+ }
13474
14193
  buildVars(entry) {
13475
14194
  return serverTemplateVars(entry.config);
13476
14195
  }
@@ -13749,11 +14468,37 @@ function readText(file) {
13749
14468
  return null;
13750
14469
  }
13751
14470
  }
14471
+ /** Whether an archive came from the schedule rather than from a person. */
14472
+ function isAutomatic(file) {
14473
+ return file.name.startsWith(`${AUTO_PREFIX}-`);
14474
+ }
13752
14475
  /** The label of one item, for `applied`; items are built before actions run. */
13753
14476
  function labelOf(plan, id) {
13754
14477
  return plan.items.find((item) => item.id === id)?.label ?? id;
13755
14478
  }
13756
14479
  /**
14480
+ * Restores only `fields` from an archived secrets document into a live one.
14481
+ *
14482
+ * The archive may predate a secret the live file holds, and the live file may hold one
14483
+ * the archive never carried: merging by field is what makes restoring the Telegram token
14484
+ * leave the DDNS credentials alone, and what clears a secret the archive does not carry
14485
+ * rather than leaving the newer value behind as if it had been restored.
14486
+ */
14487
+ function mergeIntoFile(target, archived, fields) {
14488
+ const merged = mergeSecrets(parseSecrets(readText(target) ?? ""), parseSecrets(archived), fields);
14489
+ writeFileAtomic(target, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
14490
+ }
14491
+ /**
14492
+ * What an archive says about where it came from. Both fields are absent on an archive
14493
+ * written before they existed, so a UI shows the row only when there is something to show.
14494
+ */
14495
+ function manifestInfo(manifest) {
14496
+ return {
14497
+ ...manifest.writtenBy === void 0 ? {} : { writtenBy: manifest.writtenBy },
14498
+ ...manifest.schema === void 0 ? {} : { schema: manifest.schema }
14499
+ };
14500
+ }
14501
+ /**
13757
14502
  * `servers.config.json` is only a readability gate here: the store applies the
13758
14503
  * workspace's own defaults on the next load, and an archive from another machine
13759
14504
  * need not match this one's default shapes exactly.
@@ -13774,13 +14519,17 @@ function readableWorkspaceSettings(text) {
13774
14519
  return false;
13775
14520
  }
13776
14521
  }
13777
- var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, BackupService;
14522
+ var MANIFEST, ALLOWED_ROOTS, SUFFIX, AUTO_PREFIX, RELOADABLE, SECRET_LABELS, BackupService;
13778
14523
  var init_backups = __esmMin((() => {
14524
+ init_migrations();
13779
14525
  init_parse();
14526
+ init_secret_parts();
13780
14527
  init_atomic();
13781
14528
  init_env_file();
14529
+ init_logger();
13782
14530
  init_paths();
13783
14531
  init_template();
14532
+ init_version();
13784
14533
  init_archive();
13785
14534
  init_supervisor();
13786
14535
  init_error_message();
@@ -13793,7 +14542,26 @@ var init_backups = __esmMin((() => {
13793
14542
  "data"
13794
14543
  ]);
13795
14544
  SUFFIX = ".zip";
14545
+ AUTO_PREFIX = "auto";
13796
14546
  RELOADABLE = /^global:settings$|^workspace:[^:]+:(?:settings|servers)$/;
14547
+ SECRET_LABELS = {
14548
+ password: {
14549
+ en: "Password",
14550
+ key: "settings.backup.secretPassword"
14551
+ },
14552
+ apiToken: {
14553
+ en: "API token",
14554
+ key: "settings.backup.secretApiToken"
14555
+ },
14556
+ telegram: {
14557
+ en: "Telegram bot token",
14558
+ key: "settings.backup.secretTelegram"
14559
+ },
14560
+ ddns: {
14561
+ en: "DDNS credentials",
14562
+ key: "settings.backup.secretDdns"
14563
+ }
14564
+ };
13797
14565
  BackupService = class {
13798
14566
  options;
13799
14567
  /**
@@ -13803,6 +14571,17 @@ var init_backups = __esmMin((() => {
13803
14571
  */
13804
14572
  flags = /* @__PURE__ */ new Map();
13805
14573
  refreshing = null;
14574
+ /** One scheduled pass at a time: a slow archive must not stack up behind itself. */
14575
+ running = false;
14576
+ disposed = false;
14577
+ /**
14578
+ * When this instance last attempted an automatic pass, whatever it wrote.
14579
+ *
14580
+ * The archive's own mtime is the durable record across a restart, but it cannot bound a
14581
+ * failed pass: nothing is written, so every panel tick would retry — once a second, for as
14582
+ * long as the failure lasts. This is the in-memory backstop for that case.
14583
+ */
14584
+ lastAttemptAt = null;
13806
14585
  constructor(options) {
13807
14586
  this.options = options;
13808
14587
  }
@@ -13810,6 +14589,9 @@ var init_backups = __esmMin((() => {
13810
14589
  async warm() {
13811
14590
  await this.refresh();
13812
14591
  }
14592
+ dispose() {
14593
+ this.disposed = true;
14594
+ }
13813
14595
  get directory() {
13814
14596
  return this.resolveDir();
13815
14597
  }
@@ -13821,6 +14603,8 @@ var init_backups = __esmMin((() => {
13821
14603
  enabled: config.enabled,
13822
14604
  dir: this.directory,
13823
14605
  keep: config.keep,
14606
+ schedule: { ...config.schedule },
14607
+ lastAutomaticAt: this.lastAutomatic(),
13824
14608
  includePaths: [...sources.includePaths],
13825
14609
  entries: this.entries(sources),
13826
14610
  files: this.list()
@@ -13846,7 +14630,13 @@ var init_backups = __esmMin((() => {
13846
14630
  const file = path.join(this.resolveDir(), name);
13847
14631
  return fs.existsSync(file) ? file : null;
13848
14632
  }
13849
- /** `password` encrypts the archive; it is never stored anywhere. */
14633
+ /**
14634
+ * `password` encrypts the archive; it is never stored anywhere.
14635
+ *
14636
+ * `automatic` marks an archive the schedule wrote, which is what keeps the two retention
14637
+ * counts apart: an automatic pass must never prune the backup a person took by hand, and a
14638
+ * manual one must not silently eat the history the schedule is keeping.
14639
+ */
13850
14640
  async create(options = {}) {
13851
14641
  if (!this.options.getConfig().enabled) return {
13852
14642
  ok: false,
@@ -13856,11 +14646,21 @@ var init_backups = __esmMin((() => {
13856
14646
  const wanted = options.include === void 0 ? null : new Set(options.include);
13857
14647
  const capturesGlobal = (id) => wanted === null || wanted.has(id);
13858
14648
  const capturesWorkspace = (id, suffix) => wanted === null || wanted.has(`workspace:${id}`) || wanted.has(`workspace:${id}:${suffix}`);
14649
+ /**
14650
+ * The secrets of one scope the request asked for; `null` means "every one of them".
14651
+ * `workspaceId` is how a bare `workspace:<id>` selection covers all of its leaves.
14652
+ */
14653
+ const selectedSecretFields = (entryId, path, scope, workspaceId) => {
14654
+ const parts = secretParts(scope, presentSecretKeys(parseSecrets(readText(path) ?? "")));
14655
+ const coversEntry = wanted === null || wanted.has(entryId) || workspaceId !== void 0 && wanted.has(`workspace:${workspaceId}`);
14656
+ const picked = parts.filter((part) => coversEntry || wanted.has(`${entryId}:${part.key}`) || part.fields.some((field) => wanted.has(`${entryId}:${field}`)));
14657
+ return picked.length === parts.length ? null : picked.flatMap((part) => part.fields);
14658
+ };
13859
14659
  const dir = this.resolveDir();
13860
14660
  const sources = this.options.getSources();
13861
14661
  const staging = path.join(dir, `.staging-${Date.now()}`);
13862
14662
  const createdAt = Date.now();
13863
- const name = `backup-${new Date(createdAt).toISOString().replace(/[:T]/g, "-").replace(/\.\d+Z$/, "")}-${createdAt % 1e3}${SUFFIX}`;
14663
+ const name = `${options.automatic === true ? AUTO_PREFIX : "backup"}-${new Date(createdAt).toISOString().replace(/[:T]/g, "-").replace(/\.\d+Z$/, "")}-${createdAt % 1e3}${SUFFIX}`;
13864
14664
  const destination = path.join(dir, name);
13865
14665
  try {
13866
14666
  fs.mkdirSync(staging, { recursive: true });
@@ -13868,15 +14668,41 @@ var init_backups = __esmMin((() => {
13868
14668
  const copy = (relative, source, ignoreGenerated = false) => {
13869
14669
  if (this.copyInto(staging, relative, source, ignoreGenerated)) captured++;
13870
14670
  };
14671
+ /**
14672
+ * Captures one secrets file, narrowed to the picked secrets. Copying the whole file
14673
+ * would capture the ones that were *not* picked, so a per-secret selection writes a
14674
+ * staged document holding only those — and the restore side merges by field, which
14675
+ * is what keeps a restore from wiping a secret the archive never carried.
14676
+ */
14677
+ const copySecrets = (relative, source, entryId, scope, workspaceId) => {
14678
+ const text = readText(source);
14679
+ if (text === null) return;
14680
+ const fields = selectedSecretFields(entryId, source, scope, workspaceId);
14681
+ if (fields === null) {
14682
+ copy(relative, source);
14683
+ return;
14684
+ }
14685
+ const chosen = pickSecrets(parseSecrets(text), fields);
14686
+ if (chosen === null) return;
14687
+ const target = path.join(staging, relative);
14688
+ fs.mkdirSync(path.dirname(target), { recursive: true });
14689
+ fs.writeFileSync(target, `${chosen}\n`);
14690
+ captured++;
14691
+ };
14692
+ /**
14693
+ * True when the request touches a secrets entry at all: the entry itself, one of its
14694
+ * leaves, or — for a workspace — the workspace id, which names every leaf it has.
14695
+ */
14696
+ const capturesSecrets = (entryId, workspaceId) => wanted === null || wanted.has(entryId) || workspaceId !== void 0 && wanted.has(`workspace:${workspaceId}`) || [...wanted].some((id) => id.startsWith(`${entryId}:`));
13871
14697
  if (capturesGlobal("global:settings")) copy("global/settings.json", sources.globalSettingsPath);
13872
- if (capturesGlobal("global:secrets")) copy("global/secrets.json", sources.globalSecretsPath);
14698
+ if (capturesSecrets("global:secrets")) copySecrets("global/secrets.json", sources.globalSecretsPath, "global:secrets", "global");
13873
14699
  if (capturesGlobal("global:tls")) copy("global/tls", sources.tlsDir);
13874
14700
  const data = [];
13875
14701
  for (const workspace of sources.workspaces) {
13876
14702
  const root = `workspaces/${workspace.id}`;
13877
14703
  if (capturesWorkspace(workspace.id, "settings")) copy(`${root}/settings.json`, workspace.settingsPath);
13878
14704
  if (capturesWorkspace(workspace.id, "servers")) copy(`${root}/servers.config.json`, workspace.serversPath);
13879
- if (capturesWorkspace(workspace.id, "secrets")) copy(`${root}/secrets.json`, workspace.secretsPath);
14705
+ if (capturesSecrets(`workspace:${workspace.id}:secrets`, workspace.id)) copySecrets(`${root}/secrets.json`, workspace.secretsPath, `workspace:${workspace.id}:secrets`, "workspace", workspace.id);
13880
14706
  for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
13881
14707
  if (!declared.included) continue;
13882
14708
  if (!capturesWorkspace(workspace.id, `data:${declared.path}`)) continue;
@@ -13907,6 +14733,8 @@ var init_backups = __esmMin((() => {
13907
14733
  version: 1,
13908
14734
  createdAt,
13909
14735
  hostname: os.hostname(),
14736
+ writtenBy: appVersion(),
14737
+ schema: 2,
13910
14738
  data
13911
14739
  };
13912
14740
  fs.writeFileSync(path.join(staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -14021,6 +14849,11 @@ var init_backups = __esmMin((() => {
14021
14849
  error: "the archive has no manifest"
14022
14850
  };
14023
14851
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
14852
+ plan.archive = manifestInfo(manifest);
14853
+ if (manifest.schema !== void 0 && manifest.schema > 2) return {
14854
+ ...plan,
14855
+ error: `written by home-hosted ${manifest.writtenBy ?? "a newer release"} (config schema ${manifest.schema}); this release understands schema 2`
14856
+ };
14024
14857
  const sources = this.options.getSources();
14025
14858
  const selectedIds = options.include === void 0 ? null : new Set(options.include);
14026
14859
  const actions = /* @__PURE__ */ new Map();
@@ -14077,16 +14910,32 @@ var init_backups = __esmMin((() => {
14077
14910
  writeFileAtomic(sources.globalSettingsPath, settingsText);
14078
14911
  }, ["config"]);
14079
14912
  const globalSecrets = readText(path.join(staging, "global", "secrets.json"));
14080
- if (globalSecrets !== null) addItem({
14081
- id: "global:secrets",
14082
- label: "Global secrets (password, API token)",
14083
- labelKey: "settings.backup.entryGlobalSecrets",
14084
- kind: "secrets",
14085
- restorable: true,
14086
- note: null
14087
- }, () => {
14088
- writeFileAtomic(sources.globalSecretsPath, globalSecrets, { mode: 384 });
14089
- }, ["secrets"]);
14913
+ if (globalSecrets !== null) {
14914
+ addItem({
14915
+ id: "global:secrets",
14916
+ label: "Global secrets (password, API token)",
14917
+ labelKey: "settings.backup.entryGlobalSecrets",
14918
+ kind: "secrets",
14919
+ restorable: true,
14920
+ note: null
14921
+ }, () => {
14922
+ writeFileAtomic(sources.globalSecretsPath, globalSecrets, { mode: 384 });
14923
+ }, ["secrets"]);
14924
+ for (const part of secretParts("global", presentSecretKeys(parseSecrets(globalSecrets)))) addItem({
14925
+ id: `global:secrets:${part.key}`,
14926
+ label: `Global secrets: ${SECRET_LABELS[part.key]?.en ?? part.key}`,
14927
+ labelKey: "settings.backup.restoreItemLabel",
14928
+ labelParams: {
14929
+ workspace: "global",
14930
+ item: part.key
14931
+ },
14932
+ kind: "secrets",
14933
+ restorable: true,
14934
+ note: null
14935
+ }, () => {
14936
+ mergeIntoFile(sources.globalSecretsPath, globalSecrets, part.fields);
14937
+ });
14938
+ }
14090
14939
  const tlsInArchive = path.join(staging, "global", "tls");
14091
14940
  if (fs.existsSync(tlsInArchive)) {
14092
14941
  const files = fs.readdirSync(tlsInArchive).filter((file) => fs.statSync(path.join(tlsInArchive, file)).isFile());
@@ -14214,21 +15063,38 @@ var init_backups = __esmMin((() => {
14214
15063
  });
14215
15064
  }
14216
15065
  }
14217
- if (secrets !== null) addItem({
14218
- id: `workspace:${id}:secrets`,
14219
- label: `${workspace.label}: Secrets`,
14220
- labelKey: "settings.backup.restoreItemLabel",
14221
- labelParams: {
14222
- workspace: workspace.label,
14223
- item: "secrets"
14224
- },
14225
- kind: "secrets",
14226
- workspaceId: id,
14227
- restorable: true,
14228
- note: null
14229
- }, () => {
14230
- writeFileAtomic(workspace.secretsPath, secrets, { mode: 384 });
14231
- });
15066
+ if (secrets !== null) {
15067
+ addItem({
15068
+ id: `workspace:${id}:secrets`,
15069
+ label: `${workspace.label}: Secrets`,
15070
+ labelKey: "settings.backup.restoreItemLabel",
15071
+ labelParams: {
15072
+ workspace: workspace.label,
15073
+ item: "secrets"
15074
+ },
15075
+ kind: "secrets",
15076
+ workspaceId: id,
15077
+ restorable: true,
15078
+ note: null
15079
+ }, () => {
15080
+ writeFileAtomic(workspace.secretsPath, secrets, { mode: 384 });
15081
+ });
15082
+ for (const part of secretParts("workspace", presentSecretKeys(parseSecrets(secrets)))) addItem({
15083
+ id: `workspace:${id}:secrets:${part.key}`,
15084
+ label: `${workspace.label}: ${SECRET_LABELS[part.key]?.en ?? part.key}`,
15085
+ labelKey: "settings.backup.restoreItemLabel",
15086
+ labelParams: {
15087
+ workspace: workspace.label,
15088
+ item: part.key
15089
+ },
15090
+ kind: "secrets",
15091
+ workspaceId: id,
15092
+ restorable: true,
15093
+ note: null
15094
+ }, () => {
15095
+ mergeIntoFile(workspace.secretsPath, secrets, part.fields);
15096
+ });
15097
+ }
14232
15098
  }
14233
15099
  const instanceTargets = [];
14234
15100
  for (const workspace of sources.workspaces) for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
@@ -14381,7 +15247,7 @@ var init_backups = __esmMin((() => {
14381
15247
  labelKey: "settings.backup.entryGlobalSecrets",
14382
15248
  kind: "secrets",
14383
15249
  ...missing(sources.globalSecretsPath, "settings.backup.noteMissing"),
14384
- items: []
15250
+ items: this.secretItems("global", "global:secrets", sources.globalSecretsPath)
14385
15251
  },
14386
15252
  {
14387
15253
  id: "global:tls",
@@ -14402,6 +15268,27 @@ var init_backups = __esmMin((() => {
14402
15268
  });
14403
15269
  return entries;
14404
15270
  }
15271
+ /**
15272
+ * One leaf per secret the file actually holds, so a restore can bring back the
15273
+ * password without the API token, or the Telegram token without the DDNS credentials.
15274
+ *
15275
+ * The set comes from the file's own top-level keys rather than a list here, so a
15276
+ * secret added later appears on its own — the failure mode of a second list is a
15277
+ * secret that can be captured but never restored individually.
15278
+ */
15279
+ secretItems(scope, entryId, path) {
15280
+ const present = presentSecretKeys(parseSecrets(readText(path) ?? ""));
15281
+ if (present.length === 0) return [];
15282
+ return secretParts(scope, present).map((part) => ({
15283
+ id: `${entryId}:${part.key}`,
15284
+ label: SECRET_LABELS[part.key]?.en ?? part.key,
15285
+ ...SECRET_LABELS[part.key] === void 0 ? {} : { labelKey: SECRET_LABELS[part.key].key },
15286
+ kind: "secrets",
15287
+ secret: part.key,
15288
+ included: true,
15289
+ note: null
15290
+ }));
15291
+ }
14405
15292
  workspaceItems(workspace, includePaths) {
14406
15293
  const id = workspace.id;
14407
15294
  const note = (path) => {
@@ -14435,7 +15322,8 @@ var init_backups = __esmMin((() => {
14435
15322
  kind: "secrets",
14436
15323
  included: true,
14437
15324
  ...note(workspace.secretsPath)
14438
- }
15325
+ },
15326
+ ...this.secretItems("workspace", `workspace:${id}:secrets`, workspace.secretsPath)
14439
15327
  ];
14440
15328
  for (const declared of this.declaredData(workspace.servers, includePaths)) items.push({
14441
15329
  id: `workspace:${id}:data:${declared.path}`,
@@ -14550,9 +15438,46 @@ var init_backups = __esmMin((() => {
14550
15438
  });
14551
15439
  return true;
14552
15440
  }
15441
+ /**
15442
+ * Drops the oldest archives past their own limit.
15443
+ *
15444
+ * Two budgets, because the two sources are not the same thing: `keep` is what a person
15445
+ * agreed to store, and pruning a hand-taken archive to make room for a scheduled one would
15446
+ * lose the copy they deliberately made. The scheduled pair keeps `schedule.keep` automatic
15447
+ * archives; the rest keeps `keep`.
15448
+ */
14553
15449
  prune() {
14554
- const { keep } = this.options.getConfig();
14555
- for (const file of this.list().slice(keep)) this.remove(file.name);
15450
+ const config = this.options.getConfig();
15451
+ const files = this.list();
15452
+ const automatic = files.filter(isAutomatic);
15453
+ const manual = files.filter((file) => !isAutomatic(file));
15454
+ for (const file of automatic.slice(config.schedule.keep)) this.remove(file.name);
15455
+ for (const file of manual.slice(config.keep)) this.remove(file.name);
15456
+ }
15457
+ /** When the newest automatic archive was written, or null when there is none. */
15458
+ lastAutomatic() {
15459
+ return this.list().find(isAutomatic)?.createdAt ?? null;
15460
+ }
15461
+ /**
15462
+ * Runs one scheduled pass when it is due. Modelled on the DDNS pass: cheap to call on every
15463
+ * panel tick, never blocking, and never two at once.
15464
+ */
15465
+ tick(now = Date.now()) {
15466
+ if (this.disposed || this.running) return;
15467
+ const config = this.options.getConfig();
15468
+ if (!config.enabled || config.schedule.intervalMs <= 0) return;
15469
+ const last = this.lastAutomatic();
15470
+ const latest = last === null ? this.lastAttemptAt : Math.max(last, this.lastAttemptAt ?? 0);
15471
+ if (latest !== null && now - latest < config.schedule.intervalMs) return;
15472
+ this.running = true;
15473
+ this.lastAttemptAt = now;
15474
+ this.create({ automatic: true }).then((result) => {
15475
+ if (!result.ok) logger.warn("the scheduled backup failed", result.error);
15476
+ }).catch((error) => {
15477
+ logger.error("the scheduled backup failed", error);
15478
+ }).finally(() => {
15479
+ this.running = false;
15480
+ });
14556
15481
  }
14557
15482
  resolveDir() {
14558
15483
  const configured = this.options.getConfig().dir;
@@ -15800,10 +16725,11 @@ var init_log_files = __esmMin((() => {
15800
16725
  }));
15801
16726
  //#endregion
15802
16727
  //#region src/services/notifications.ts
15803
- var REASON_LABEL, TITLE, NotificationService;
16728
+ var REASON_LABEL, TITLE, COVERS, NotificationService;
15804
16729
  var init_notifications = __esmMin((() => {
15805
16730
  init_logger();
15806
16731
  init_telegram();
16732
+ init_webhook();
15807
16733
  init_error_message();
15808
16734
  REASON_LABEL = {
15809
16735
  "crash": "gave up restarting",
@@ -15827,122 +16753,230 @@ var init_notifications = __esmMin((() => {
15827
16753
  "ddns": "🌐 DNS updated",
15828
16754
  "ddns-error": "🌐 DNS update failed"
15829
16755
  };
16756
+ COVERS = {
16757
+ "crash": (policy) => policy.onCrash,
16758
+ "rss": (policy) => policy.onCrash,
16759
+ "unhealthy": (policy) => policy.onUnhealthy,
16760
+ "forced-restart": (policy) => policy.onForcedRestart,
16761
+ "recovered": (policy) => policy.onRecovered,
16762
+ "host": (policy) => policy.onHost,
16763
+ "host-recovered": (policy) => policy.onHost,
16764
+ "ddns": (policy) => policy.onDdns,
16765
+ "ddns-error": (policy) => policy.onDdns
16766
+ };
15830
16767
  NotificationService = class {
15831
16768
  secrets;
15832
16769
  getConfig;
15833
16770
  cooldowns = /* @__PURE__ */ new Map();
15834
- lastResult = null;
15835
- lastResultAt = null;
16771
+ results = /* @__PURE__ */ new Map();
15836
16772
  constructor(secrets, getConfig) {
15837
16773
  this.secrets = secrets;
15838
16774
  this.getConfig = getConfig;
15839
16775
  }
15840
- get telegramTokenSet() {
15841
- return this.secrets.telegramTokenSet;
16776
+ /** The general block, as the page edits it. */
16777
+ defaults() {
16778
+ return { ...this.getConfig().defaults };
15842
16779
  }
15843
- status() {
15844
- const telegram = this.getConfig().telegram;
15845
- return {
15846
- enabled: telegram.enabled,
15847
- tokenSet: this.secrets.telegramTokenSet,
15848
- chatId: telegram.chatId,
15849
- onCrash: telegram.onCrash,
15850
- onUnhealthy: telegram.onUnhealthy,
15851
- onForcedRestart: telegram.onForcedRestart,
15852
- onRecovered: telegram.onRecovered,
15853
- onHost: telegram.onHost,
15854
- onDdns: telegram.onDdns,
15855
- cooldownMs: telegram.cooldownMs,
15856
- lastResult: this.lastResult,
15857
- lastResultAt: this.lastResultAt
16780
+ /**
16781
+ * Every destination as a row: its own fields, the policy it will actually use, and its last result.
16782
+ *
16783
+ * The page shows this rather than the raw config, so an inheriting destination displays the
16784
+ * inherited toggles — the values a send will use, not the stale ones on disk.
16785
+ */
16786
+ destinations() {
16787
+ return this.getConfig().destinations.map((target) => {
16788
+ const result = this.results.get(target.id) ?? {
16789
+ lastResult: null,
16790
+ lastResultAt: null
16791
+ };
16792
+ const common = {
16793
+ id: target.id,
16794
+ kind: target.kind,
16795
+ enabled: target.enabled,
16796
+ useOwnSettings: target.useOwnSettings,
16797
+ ...this.effectivePolicy(target),
16798
+ lastResult: result.lastResult,
16799
+ lastResultAt: result.lastResultAt
16800
+ };
16801
+ if (target.kind === "telegram") return {
16802
+ ...common,
16803
+ chatId: target.chatId,
16804
+ tokenSet: this.secrets.telegramTokenSet(target.id),
16805
+ url: null,
16806
+ method: null,
16807
+ format: null,
16808
+ headers: null
16809
+ };
16810
+ return {
16811
+ ...common,
16812
+ chatId: null,
16813
+ tokenSet: false,
16814
+ url: target.url,
16815
+ method: target.method,
16816
+ format: target.format,
16817
+ headers: target.headers
16818
+ };
16819
+ });
16820
+ }
16821
+ /** The policy a destination actually uses: its own, or the general block's. */
16822
+ effectivePolicy(target) {
16823
+ if (target.useOwnSettings) return {
16824
+ onCrash: target.onCrash,
16825
+ onUnhealthy: target.onUnhealthy,
16826
+ onForcedRestart: target.onForcedRestart,
16827
+ onRecovered: target.onRecovered,
16828
+ onHost: target.onHost,
16829
+ onDdns: target.onDdns,
16830
+ cooldownMs: target.cooldownMs
15858
16831
  };
16832
+ return { ...this.getConfig().defaults };
15859
16833
  }
15860
- /** Enabled for this reason *and* outside its cooldown window. */
16834
+ /**
16835
+ * The destinations that would deliver this event right now.
16836
+ *
16837
+ * Per destination rather than one boolean for the service: a single answer would either send a
16838
+ * host warning somewhere switched off for it, or silence a crash because another destination's
16839
+ * cooldown was still running.
16840
+ */
16841
+ targetsFor(event, now = Date.now()) {
16842
+ return this.getConfig().destinations.filter((target) => {
16843
+ if (!target.enabled) return false;
16844
+ const policy = this.effectivePolicy(target);
16845
+ if (!COVERS[event.reason](policy)) return false;
16846
+ const until = this.cooldowns.get(this.cooldownKey(target, event)) ?? 0;
16847
+ return !(policy.cooldownMs > 0 && until > now);
16848
+ });
16849
+ }
16850
+ /** Whether *any* destination would deliver this event. */
15861
16851
  shouldNotify(event, now = Date.now()) {
15862
- const telegram = this.getConfig().telegram;
15863
- if (!telegram.enabled) return false;
15864
- if (!{
15865
- "crash": telegram.onCrash,
15866
- "unhealthy": telegram.onUnhealthy,
15867
- "forced-restart": telegram.onForcedRestart,
15868
- "recovered": telegram.onRecovered,
15869
- "rss": telegram.onCrash,
15870
- "host": telegram.onHost,
15871
- "host-recovered": telegram.onHost,
15872
- "ddns": telegram.onDdns,
15873
- "ddns-error": telegram.onDdns
15874
- }[event.reason]) return false;
15875
- const until = this.cooldowns.get(`${event.serverId}:${event.reason}`) ?? 0;
15876
- return !(telegram.cooldownMs > 0 && until > now);
15877
- }
15878
- /** Starts the cooldown window for this event, so a flapping server stays quiet. */
16852
+ return this.targetsFor(event, now).length > 0;
16853
+ }
16854
+ /** Starts the cooldown window for every destination about to be asked to send. */
15879
16855
  markSent(event, now = Date.now()) {
15880
- this.cooldowns.set(`${event.serverId}:${event.reason}`, now + this.getConfig().telegram.cooldownMs);
16856
+ for (const target of this.targetsFor(event, now)) this.cooldowns.set(this.cooldownKey(target, event), now + this.effectivePolicy(target).cooldownMs);
15881
16857
  }
15882
- /** Fire-and-forget by design: supervision must never wait on a chat API. */
16858
+ /** Fire-and-forget by design: supervision must never wait on a chat or webhook API. */
15883
16859
  notify(event) {
15884
16860
  this.dispatch(event).catch((error) => {
15885
16861
  logger.warn(`notification failed: ${errorMessage(error)}`);
15886
16862
  });
15887
16863
  }
15888
16864
  async dispatch(event) {
15889
- if (!this.shouldNotify(event)) return false;
16865
+ const targets = this.targetsFor(event);
16866
+ if (targets.length === 0) return false;
15890
16867
  this.markSent(event);
15891
- return this.sendTelegram(formatTelegramMessage(TITLE[event.reason], [`${event.label} (${event.serverId}) ${REASON_LABEL[event.reason]}`, event.detail]));
16868
+ const title = TITLE[event.reason];
16869
+ const message = `${event.label} (${event.serverId}) ${REASON_LABEL[event.reason]}\n${event.detail}`;
16870
+ return (await Promise.all(targets.map((target) => this.deliver(target, title, message, event)))).includes(true);
15892
16871
  }
15893
- /** Used by the "send test" button in settings. */
15894
- async sendTest(overrides = {}) {
15895
- const chatId = overrides.chatId ?? this.getConfig().telegram.chatId;
15896
- if (chatId.length === 0) return {
16872
+ /** One destination, one event. A throw is recorded against it and nothing else. */
16873
+ async deliver(target, title, message, event) {
16874
+ try {
16875
+ const result = target.kind === "telegram" ? await this.sendTelegram(target, title, message) : await sendWebhook(target, {
16876
+ title,
16877
+ message,
16878
+ reason: event.reason,
16879
+ serverId: event.serverId,
16880
+ label: event.label
16881
+ });
16882
+ this.remember(target.id, result.ok ? "sent" : result.error ?? "send failed");
16883
+ return result.ok;
16884
+ } catch (error) {
16885
+ this.remember(target.id, errorMessage(error));
16886
+ return false;
16887
+ }
16888
+ }
16889
+ /** A probe for one destination. */
16890
+ async sendTest(id, overrides = {}) {
16891
+ const target = this.getConfig().destinations.find((entry) => entry.id === id);
16892
+ if (target === void 0) return {
16893
+ ok: false,
16894
+ error: "no such destination"
16895
+ };
16896
+ const result = target.kind === "telegram" ? await this.testTelegram(id, target.chatId, overrides.chatId, overrides.botToken) : await this.testWebhook(target, id);
16897
+ this.remember(id, result.ok ? "test message sent" : result.error ?? "test failed");
16898
+ return result;
16899
+ }
16900
+ /**
16901
+ * A Telegram destination's own token, with the chat id and the token both overridable.
16902
+ *
16903
+ * The override is what lets a token be tried before it is saved — the route advertises it and
16904
+ * `detect-chats` honours it, so ignoring it here made "send a test" fail on a token the page was
16905
+ * still holding.
16906
+ */
16907
+ async testTelegram(id, chatId, overrideChatId, overrideToken) {
16908
+ const chat = (overrideChatId ?? chatId).trim();
16909
+ if (chat.length === 0) return {
15897
16910
  ok: false,
15898
16911
  error: "no chat id configured"
15899
16912
  };
15900
- const token = this.resolveToken(overrides.botToken);
16913
+ const token = overrideToken !== void 0 && overrideToken.length > 0 ? overrideToken : this.secrets.telegramToken(id);
15901
16914
  if (token === null) return {
15902
16915
  ok: false,
15903
- error: "no bot token configured"
16916
+ error: "no bot token configured for this destination"
15904
16917
  };
15905
- const result = await sendTelegramMessage(token, chatId, formatTelegramMessage("✅ home-hosted test", ["notifications are wired up correctly"]));
15906
- this.remember(result.ok ? "test message sent" : result.error ?? "test failed");
16918
+ return sendTelegramMessage(token, chat, formatTelegramMessage("✅ home-hosted test", ["notifications are wired up correctly"]));
16919
+ }
16920
+ /**
16921
+ * A webhook probe, for a saved destination or for a draft the page has not saved.
16922
+ *
16923
+ * A draft is passed whole, so a URL and its headers can be tried before they are stored — and so
16924
+ * a destination's own "test" tests *that* destination, not whichever one comes first.
16925
+ */
16926
+ async testWebhook(target, id) {
16927
+ const problem = targetProblem(target, { requireUrl: true });
16928
+ if (problem !== null) return {
16929
+ ok: false,
16930
+ error: problem
16931
+ };
16932
+ const result = await sendWebhook(target, {
16933
+ title: "home-hosted test",
16934
+ message: "notifications are wired up correctly",
16935
+ reason: "test",
16936
+ serverId: "",
16937
+ label: ""
16938
+ });
16939
+ if (id !== void 0) this.remember(id, result.ok ? "test message sent" : result.error ?? "test failed");
15907
16940
  return result;
15908
16941
  }
15909
- async detectChats(overrides = {}) {
15910
- const token = this.resolveToken(overrides.botToken);
15911
- if (token === null) return {
16942
+ /** Lists the chats a destination's bot can see, so its chat id can be picked. */
16943
+ async detectChats(id, overrides = {}) {
16944
+ const token = (overrides.botToken ?? this.secrets.telegramToken(id) ?? "").trim();
16945
+ if (token.length === 0) return {
15912
16946
  ok: false,
15913
16947
  chats: [],
15914
- error: "no bot token configured"
16948
+ error: "no bot token configured for this destination"
15915
16949
  };
15916
16950
  const result = await listTelegramChats(token);
15917
- this.remember(result.ok ? `${result.chats.length} chat(s) found` : result.error ?? "detect failed");
16951
+ this.remember(id, result.ok ? `${result.chats.length} chat(s) found` : result.error ?? "detect failed");
15918
16952
  return result;
15919
16953
  }
15920
- /** Verifies a token without sending anything. */
16954
+ /** Verifies a token without storing or sending anything. */
15921
16955
  async verifyToken(token) {
15922
16956
  return verifyTelegramToken(token);
15923
16957
  }
15924
- resolveToken(tokenOverride) {
15925
- const token = tokenOverride?.trim() ?? this.secrets.telegramToken ?? "";
15926
- return token.length > 0 ? token : null;
16958
+ cooldownKey(target, event) {
16959
+ return `${target.id}:${event.serverId}:${event.reason}`;
15927
16960
  }
15928
- async sendTelegram(html) {
15929
- const telegram = this.getConfig().telegram;
15930
- if (telegram.chatId.length === 0) {
15931
- this.remember("no chat id configured");
15932
- return false;
15933
- }
15934
- const token = this.resolveToken();
15935
- if (token === null) {
15936
- this.remember("no bot token configured");
15937
- return false;
15938
- }
15939
- const result = await sendTelegramMessage(token, telegram.chatId, html);
15940
- this.remember(result.ok ? "sent" : result.error ?? "send failed");
15941
- return result.ok;
15942
- }
15943
- remember(message) {
15944
- this.lastResult = message;
15945
- this.lastResultAt = Date.now();
16961
+ async sendTelegram(target, title, message) {
16962
+ if (target.chatId.length === 0) return {
16963
+ ok: false,
16964
+ error: "no chat id configured"
16965
+ };
16966
+ const token = this.secrets.telegramToken(target.id);
16967
+ if (token === null) return {
16968
+ ok: false,
16969
+ error: "no bot token configured for this destination"
16970
+ };
16971
+ const body = message.split("\n").filter((line) => line.length > 0);
16972
+ return sendTelegramMessage(token, target.chatId, formatTelegramMessage(title, body));
16973
+ }
16974
+ /** Records one destination's outcome, keyed by its id. */
16975
+ remember(id, message) {
16976
+ this.results.set(id, {
16977
+ lastResult: message,
16978
+ lastResultAt: Date.now()
16979
+ });
15946
16980
  }
15947
16981
  };
15948
16982
  }));
@@ -15981,8 +17015,7 @@ function stateSignature(state) {
15981
17015
  workspace.ddns.ipv6,
15982
17016
  ...workspace.ddns.records.map((record) => `${record.host}:${record.type}:${record.state}:${record.ip}`)
15983
17017
  ].join(":"),
15984
- workspace.notifications.telegram.enabled,
15985
- workspace.notifications.telegram.tokenSet,
17018
+ ...(workspace.notifications.destinations ?? []).map((destination) => `${destination.id}:${destination.kind}:${destination.enabled}:${destination.tokenSet}:${destination.lastResult ?? ""}`),
15986
17019
  ...workspace.servers.map((server) => [
15987
17020
  server.id,
15988
17021
  server.status,
@@ -16213,6 +17246,7 @@ var init_panel = __esmMin((() => {
16213
17246
  async dispose() {
16214
17247
  this.disposed = true;
16215
17248
  clearInterval(this.timer);
17249
+ this.options.backups.dispose();
16216
17250
  await Promise.all(this.workspaces().map((runtime) => runtime.supervisor.dispose()));
16217
17251
  for (const runtime of this.workspaces()) this.disposeRuntime(runtime);
16218
17252
  }
@@ -16326,6 +17360,7 @@ var init_panel = __esmMin((() => {
16326
17360
  if (this.disposed) return;
16327
17361
  const now = Date.now();
16328
17362
  await this.options.hostMonitor.tick(now);
17363
+ this.options.backups.tick(now);
16329
17364
  for (const runtime of this.workspaces()) runtime.ddns.tick(now);
16330
17365
  const proxy = this.options.proxy();
16331
17366
  if (proxy !== null) await proxy.sync().catch((error) => {
@@ -18168,7 +19203,10 @@ function targets(configOverride) {
18168
19203
  validate: (raw) => {
18169
19204
  let defaults = {};
18170
19205
  try {
18171
- defaults = parseWorkspaceSettings(JSON.parse(fs.readFileSync(workspaceSettingsPath(workspace.id), "utf8"))).config?.defaults ?? {};
19206
+ const settingsRaw = JSON.parse(fs.readFileSync(workspaceSettingsPath(workspace.id), "utf8"));
19207
+ const meta = typeof settingsRaw.meta === "object" && settingsRaw.meta !== null ? settingsRaw.meta : {};
19208
+ const from = typeof meta.schema === "number" ? meta.schema : 1;
19209
+ defaults = (planConfigMigrations(from).steps.length > 0 ? applyConfigMigrations(settingsRaw, from).config : settingsRaw)?.defaults ?? {};
18172
19210
  } catch {}
18173
19211
  return parseServersFile(raw, defaults).errors[0] ?? null;
18174
19212
  }
@@ -18192,7 +19230,18 @@ async function runMigrate(config, dryRun, yes) {
18192
19230
  const from = typeof meta.schema === "number" ? meta.schema : 1;
18193
19231
  const plan = planConfigMigrations(from);
18194
19232
  if (plan.tooNew) fail(`${target.file} was written by home-hosted ${meta.writtenBy ?? "a newer release"} (config schema ${from});\n this release understands schema ${plan.to}. Install that version, or edit the file yourself.`);
18195
- const problem = target.validate(raw);
19233
+ /**
19234
+ * Validate what this release will actually read: the file as-is when it needs no migration,
19235
+ * otherwise the result the steps produce.
19236
+ *
19237
+ * The parsed check cannot be run on the raw file when a migration is pending — the reader
19238
+ * refuses an older schema by design (`readStamp`), which would make `migrate` refuse every
19239
+ * config it exists to lift. `checkShape` below catches a file that is not even JSON-shaped
19240
+ * before the steps touch it.
19241
+ */
19242
+ const migrated = plan.steps.length === 0 ? raw : applyConfigMigrations(raw, from).config;
19243
+ if (plan.steps.length > 0 && !isRecord(migrated)) fail(`${target.file} did not produce an object when migrated — refusing to write it`);
19244
+ const problem = target.validate(migrated);
18196
19245
  if (problem !== null) fail(`${target.file} is not a config this release can read: ${problem}`);
18197
19246
  if (plan.reached !== plan.to) fail(`${target.file} is schema ${from} and this release can only lift it to schema ${plan.reached} — the migration chain in configMigrations has a gap to schema ${plan.to}.
18198
19247
  That is a bug in this release: report it rather than editing the file.`);
@@ -18213,7 +19262,6 @@ async function runMigrate(config, dryRun, yes) {
18213
19262
  return;
18214
19263
  }
18215
19264
  }
18216
- const { config: migrated } = applyConfigMigrations(raw, from);
18217
19265
  const migratedProblem = target.validate(migrated);
18218
19266
  if (migratedProblem !== null) fail(`the migration produced a config this release cannot read:\n ${migratedProblem}`);
18219
19267
  fs.copyFileSync(target.file, `${target.file}.bak`);
@@ -18238,6 +19286,7 @@ var init_migrate = __esmMin((() => {
18238
19286
  init_paths();
18239
19287
  init_version();
18240
19288
  init_error_message();
19289
+ init_shape();
18241
19290
  migrateArgs = {
18242
19291
  config: {
18243
19292
  type: "string",