home-hosted 0.8.3 → 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.
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, 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, telegramStatusSchema, 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, 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?",
@@ -1384,10 +1478,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1384
1478
  privateKey: "string >= 1"
1385
1479
  }).onUndeclaredKey("reject");
1386
1480
  proxyEngineInstallSchema = type({ version: "string = \"\"" }).onUndeclaredKey("reject");
1387
- telegramStatusSchema = type({
1388
- enabled: "boolean",
1389
- tokenSet: "boolean",
1390
- chatId: "string",
1481
+ notificationPolicyStatusShape = {
1391
1482
  onCrash: "boolean",
1392
1483
  onUnhealthy: "boolean",
1393
1484
  onForcedRestart: "boolean",
@@ -1396,8 +1487,24 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1396
1487
  onHost: "boolean",
1397
1488
  /** Dynamic DNS, when this panel knows about it (optional for an older panel). */
1398
1489
  onDdns: "boolean?",
1399
- cooldownMs: "number",
1400
- /** 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. */
1401
1508
  lastResult: "string | null",
1402
1509
  lastResultAt: "number | null"
1403
1510
  });
@@ -1405,7 +1512,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1405
1512
  id: "string | number",
1406
1513
  title: "string"
1407
1514
  }).array() });
1408
- 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
+ });
1409
1521
  sessionViewSchema = type({
1410
1522
  authenticated: "boolean",
1411
1523
  authRequired: "boolean",
@@ -1526,14 +1638,24 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1526
1638
  }).array()
1527
1639
  });
1528
1640
  backupsViewSchema = type({
1529
- enabled: "boolean",
1530
- dir: "string",
1531
- 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",
1532
1654
  /** Extra paths from the global config, in addition to each server's own. */
1533
- includePaths: type("string[]"),
1655
+ "includePaths": type("string[]"),
1534
1656
  /** Every selectable entry, global first, then each workspace. */
1535
- entries: backupEntrySchema.array(),
1536
- files: backupFileSchema.array()
1657
+ "entries": backupEntrySchema.array(),
1658
+ "files": backupFileSchema.array()
1537
1659
  });
1538
1660
  restoreItemSchema = type({
1539
1661
  /** A backup entry or leaf id (`workspace:<id>:servers`, `data:<path>`, ...). */
@@ -1662,11 +1784,11 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1662
1784
  "files?": "number.integer >= 1",
1663
1785
  /** `owner/name` of the UI's own repository, for `ui-update`. */
1664
1786
  "repo?": "string",
1665
- /** 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. */
1666
1788
  "tag?": "string",
1667
1789
  /** The release asset name, e.g. `home-hosted-ui-noc-console`. */
1668
1790
  "asset?": "string",
1669
- /** 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. */
1670
1792
  "unix?": "number.integer >= 0"
1671
1793
  });
1672
1794
  uiStatusSchema = type({
@@ -1766,10 +1888,19 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1766
1888
  lines: logLineSchema.array()
1767
1889
  });
1768
1890
  notificationActionSchema = type({
1769
- /** 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. */
1770
1892
  botToken: "string?",
1771
1893
  chatId: "string?"
1772
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");
1773
1904
  telegramTokenSchema = type({ botToken: "string >= 1" }).onUndeclaredKey("reject");
1774
1905
  apiErrorSchema = type({
1775
1906
  "message": "string",
@@ -1967,6 +2098,20 @@ function isPlainDdns(value) {
1967
2098
  return "plain" in value;
1968
2099
  }
1969
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
+ /**
1970
2115
  * Sealed on disk, or `{ provider, values }` waiting for the sealing write. A bare
1971
2116
  * field map — written before the provider was recorded — no longer reads, because
1972
2117
  * there is nothing to say which provider those fields belong to.
@@ -2048,7 +2193,7 @@ function openDdns(accountId, sealed, key) {
2048
2193
  return null;
2049
2194
  }
2050
2195
  }
2051
- 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;
2052
2197
  var init_secrets = __esmMin((() => {
2053
2198
  init_atomic();
2054
2199
  init_logger();
@@ -2061,6 +2206,7 @@ var init_secrets = __esmMin((() => {
2061
2206
  };
2062
2207
  KEYLEN = 64;
2063
2208
  SALT_BYTES = 16;
2209
+ LEGACY_TELEGRAM_ID$1 = "telegram";
2064
2210
  DDNS_KEYLEN = 32;
2065
2211
  DDNS_IV_BYTES = 12;
2066
2212
  DDNS_AAD_PREFIX = "hh-ddns-v1:";
@@ -2138,11 +2284,17 @@ var init_secrets = __esmMin((() => {
2138
2284
  get apiTokenHint() {
2139
2285
  return this.apiToken?.hint ?? null;
2140
2286
  }
2141
- get telegramToken() {
2142
- 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;
2143
2294
  }
2144
- get telegramTokenSet() {
2145
- 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();
2146
2298
  }
2147
2299
  setPassword(password, options = {}) {
2148
2300
  const record = hashPassword(password, options);
@@ -2177,11 +2329,24 @@ var init_secrets = __esmMin((() => {
2177
2329
  apiToken: null
2178
2330
  });
2179
2331
  }
2180
- setTelegramToken(token) {
2332
+ setTelegramToken(id, token) {
2181
2333
  const trimmed = token?.trim() ?? "";
2334
+ const current = { ...this.load().telegram };
2335
+ if (trimmed.length > 0) current[id] = trimmed;
2336
+ else delete current[id];
2182
2337
  this.save({
2183
2338
  ...this.load(),
2184
- 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
2185
2350
  });
2186
2351
  }
2187
2352
  /** Ids that have something stored, whether or not a config still declares them. */
@@ -2286,7 +2451,7 @@ var init_secrets = __esmMin((() => {
2286
2451
  const empty = {
2287
2452
  password: null,
2288
2453
  apiToken: null,
2289
- telegram: null,
2454
+ telegram: {},
2290
2455
  ddns: {},
2291
2456
  ddnsKdf: null
2292
2457
  };
@@ -2303,7 +2468,7 @@ var init_secrets = __esmMin((() => {
2303
2468
  return this.inScope({
2304
2469
  password: parsed?.password ?? null,
2305
2470
  apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
2306
- telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null,
2471
+ telegram: readTelegramTokens(parsed?.telegram),
2307
2472
  ddns: readStoredDdns(parsed?.ddns),
2308
2473
  ddnsKdf: readDdnsKdf(parsed?.ddnsKdf)
2309
2474
  });
@@ -2316,7 +2481,7 @@ var init_secrets = __esmMin((() => {
2316
2481
  inScope(contents) {
2317
2482
  if (this.scope === "global") return {
2318
2483
  ...contents,
2319
- telegram: null,
2484
+ telegram: {},
2320
2485
  ddns: {},
2321
2486
  ddnsKdf: null
2322
2487
  };
@@ -3121,7 +3286,10 @@ function buildWorkspaceView(source) {
3121
3286
  logsDir: source.logsDir,
3122
3287
  defaults: source.store.defaults,
3123
3288
  logs: source.store.logs,
3124
- notifications: { telegram: source.notifications.status() },
3289
+ notifications: {
3290
+ defaults: source.notifications.defaults(),
3291
+ destinations: source.notifications.destinations()
3292
+ },
3125
3293
  ddns: source.ddns.view,
3126
3294
  serverCount: servers.length,
3127
3295
  runningCount: servers.filter((server) => server.status === "running").length,
@@ -3717,9 +3885,55 @@ var init_control = __esmMin((() => {
3717
3885
  }));
3718
3886
  //#endregion
3719
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
+ }
3720
3934
  /** What would have to run to bring `from` up to `to`. */
3721
3935
  function planConfigMigrations(from, options = {}) {
3722
- const to = options.to ?? 1;
3936
+ const to = options.to ?? 2;
3723
3937
  const steps = (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to);
3724
3938
  return {
3725
3939
  from,
@@ -3729,19 +3943,42 @@ function planConfigMigrations(from, options = {}) {
3729
3943
  reached: steps.at(-1)?.to ?? from
3730
3944
  };
3731
3945
  }
3732
- /** 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
+ */
3733
3954
  function applyConfigMigrations(config, from, options = {}) {
3734
- const { steps } = planConfigMigrations(from, options);
3955
+ const plan = planConfigMigrations(from, options);
3735
3956
  let current = config;
3736
- for (const step of steps) current = step.apply(current);
3737
- return {
3957
+ for (const step of plan.steps) current = step.apply(current);
3958
+ if (plan.steps.length === 0) return {
3738
3959
  config: current,
3739
- 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
3740
3972
  };
3741
3973
  }
3742
- var configMigrations;
3974
+ var LEGACY_TELEGRAM_ID, configMigrations;
3743
3975
  var init_migrations = __esmMin((() => {
3744
- 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
+ }];
3745
3982
  }));
3746
3983
  //#endregion
3747
3984
  //#region src/config/parse.ts
@@ -3805,10 +4042,10 @@ function readStamp(raw, options) {
3805
4042
  const meta = isRecord(raw.meta) ? raw.meta : null;
3806
4043
  const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
3807
4044
  const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
3808
- if (schemaVersion > 1) return {
4045
+ if (schemaVersion > 2) return {
3809
4046
  schemaVersion,
3810
4047
  writtenBy,
3811
- 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`
3812
4049
  };
3813
4050
  const { steps } = planConfigMigrations(schemaVersion, options);
3814
4051
  if (steps.length > 0) return {
@@ -3965,6 +4202,7 @@ function validateCrossField(servers) {
3965
4202
  for (const server of servers) {
3966
4203
  const { health } = server;
3967
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`);
3968
4206
  for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
3969
4207
  else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
3970
4208
  }
@@ -3992,7 +4230,7 @@ function stampConfig(draft) {
3992
4230
  ...$schema === void 0 ? {} : { $schema },
3993
4231
  meta: {
3994
4232
  writtenBy: appVersion(),
3995
- schema: 1
4233
+ schema: 2
3996
4234
  },
3997
4235
  ...rest
3998
4236
  };
@@ -4043,7 +4281,7 @@ function mergeGroup(target, patch) {
4043
4281
  }
4044
4282
  return merged;
4045
4283
  }
4046
- 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;
4047
4285
  var init_patch = __esmMin((() => {
4048
4286
  init_shape();
4049
4287
  SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
@@ -4052,9 +4290,10 @@ var init_patch = __esmMin((() => {
4052
4290
  "stop"
4053
4291
  ]);
4054
4292
  CONTROL_MERGE_KEYS = /* @__PURE__ */ new Set(["auth", "tls"]);
4055
- NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["telegram"]);
4293
+ NOTIFICATION_MERGE_KEYS = /* @__PURE__ */ new Set(["defaults"]);
4056
4294
  DDNS_MERGE_KEYS = /* @__PURE__ */ new Set(["ipv4", "ipv6"]);
4057
4295
  PROXY_MERGE_KEYS = /* @__PURE__ */ new Set(["dns01"]);
4296
+ BACKUP_MERGE_KEYS = /* @__PURE__ */ new Set(["schedule"]);
4058
4297
  EMPTY_MERGE_KEYS = /* @__PURE__ */ new Set();
4059
4298
  }));
4060
4299
  //#endregion
@@ -4244,7 +4483,7 @@ var init_settings$1 = __esmMin((() => {
4244
4483
  updateBackups(patch) {
4245
4484
  const draft = structuredClone(this.raw);
4246
4485
  draft.backups = { ...draft.backups ?? {} };
4247
- applyPatch(draft.backups, patch, EMPTY_MERGE_KEYS);
4486
+ applyPatch(draft.backups, patch, BACKUP_MERGE_KEYS);
4248
4487
  const backups = this.parseGroup(backupsSchema, draft.backups, "backups");
4249
4488
  this.commit(draft);
4250
4489
  return backups;
@@ -5806,6 +6045,171 @@ var init_ddns$2 = __esmMin((() => {
5806
6045
  BY_ID$1 = new Map(DDNS_PROVIDERS.map((provider) => [provider.id, provider]));
5807
6046
  }));
5808
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
5809
6213
  //#region src/config/store.ts
5810
6214
  function readFile(file) {
5811
6215
  let text;
@@ -5851,6 +6255,8 @@ var init_store = __esmMin((() => {
5851
6255
  init_settings$1();
5852
6256
  init_atomic();
5853
6257
  init_ddns$2();
6258
+ init_webhook();
6259
+ init_contracts();
5854
6260
  WorkspaceStore = class {
5855
6261
  workspaceId;
5856
6262
  settingsFile;
@@ -6070,6 +6476,13 @@ var init_store = __esmMin((() => {
6070
6476
  draft.notifications = { ...draft.notifications ?? {} };
6071
6477
  applyPatch(draft.notifications, patch, NOTIFICATION_MERGE_KEYS);
6072
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
+ }
6073
6486
  this.commitSettings(draft);
6074
6487
  return notifications;
6075
6488
  }
@@ -6769,58 +7182,77 @@ var init_telegram = __esmMin((() => {
6769
7182
  //#endregion
6770
7183
  //#region src/api/notifications.ts
6771
7184
  /**
6772
- * 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
6773
7186
  * servers config, so notification *policy* and the *credential* stay separate.
6774
7187
  *
6775
- * 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.
6776
7190
  */
6777
7191
  function createNotificationsRoute(deps) {
6778
- 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({
6779
7194
  tags: ["notifications"],
6780
- summary: "Store the Telegram bot token (verified first)",
7195
+ summary: "Store a Telegram destination's bot token (verified first)",
6781
7196
  responses: {
6782
7197
  200: { description: "Stored" },
6783
7198
  400: ERROR_RESPONSES[400]
6784
7199
  }
6785
- }), validate("query", workspaceQuerySchema), validate("json", telegramTokenSchema), async (c) => {
6786
- 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;
6787
7203
  const { botToken } = c.req.valid("json");
6788
7204
  const verified = await runtime.notifications.verifyToken(botToken);
6789
7205
  if (!verified.ok) throw new DetailedError(`telegram rejected the token: ${verified.error ?? "unknown error"}`, {
6790
7206
  statusCode: 400,
6791
7207
  code: "TELEGRAM_TOKEN_REJECTED"
6792
7208
  });
6793
- runtime.secrets.setTelegramToken(botToken);
7209
+ runtime.secrets.setTelegramToken(id, botToken);
6794
7210
  forgetBots();
6795
7211
  return c.json({
6796
7212
  ok: true,
6797
7213
  username: verified.username ?? null
6798
7214
  });
6799
- }).delete("/notifications/token", describeRoute({
7215
+ }).delete("/notifications/destinations/:id/token", describeRoute({
6800
7216
  tags: ["notifications"],
6801
- summary: "Forget the Telegram bot token",
7217
+ summary: "Forget a Telegram destination's bot token",
6802
7218
  responses: { 200: { description: "Removed" } }
6803
- }), validate("query", workspaceQuerySchema), (c) => {
6804
- 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);
6805
7221
  forgetBots();
6806
7222
  return c.json({ ok: true });
6807
- }).post("/notifications/test", describeRoute({
7223
+ }).post("/notifications/destinations/:id/test", describeRoute({
6808
7224
  tags: ["notifications"],
6809
- summary: "Send a test message",
7225
+ summary: "Send a test message to one destination",
6810
7226
  responses: {
6811
7227
  200: { description: "Sent or refused" },
6812
7228
  400: ERROR_RESPONSES[400]
6813
7229
  }
6814
- }), validate("query", workspaceQuerySchema), validate("json", notificationActionSchema), async (c) => {
6815
- 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"));
6816
7232
  if (!result.ok) throw new DetailedError(result.error ?? "the test message failed", {
6817
7233
  statusCode: 400,
6818
7234
  code: "TELEGRAM_SEND_FAILED"
6819
7235
  });
6820
7236
  return c.json(result);
6821
- }).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({
6822
7254
  tags: ["notifications"],
6823
- summary: "List the chats the bot can see",
7255
+ summary: "List the chats a Telegram destination's bot can see",
6824
7256
  responses: {
6825
7257
  200: {
6826
7258
  description: "Chats",
@@ -6828,8 +7260,8 @@ function createNotificationsRoute(deps) {
6828
7260
  },
6829
7261
  400: ERROR_RESPONSES[400]
6830
7262
  }
6831
- }), validate("query", workspaceQuerySchema), validate("json", notificationActionSchema), async (c) => {
6832
- 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"));
6833
7265
  if (!result.ok) throw new DetailedError(result.error ?? "could not list chats", {
6834
7266
  statusCode: 400,
6835
7267
  code: "TELEGRAM_LIST_FAILED"
@@ -10531,7 +10963,10 @@ function buildWorkspaceSettings(runtime) {
10531
10963
  configError: runtime.store.configError,
10532
10964
  defaults: runtime.store.defaults,
10533
10965
  logs: runtime.store.logs,
10534
- notifications: { telegram: runtime.notifications.status() }
10966
+ notifications: {
10967
+ defaults: runtime.notifications.defaults(),
10968
+ destinations: runtime.notifications.destinations()
10969
+ }
10535
10970
  };
10536
10971
  }
10537
10972
  function createSettingsRoute(deps) {
@@ -10723,7 +11158,10 @@ function createSettingsRoute(deps) {
10723
11158
  try {
10724
11159
  if (patch.defaults !== void 0) runtime.store.updateDefaults(patch.defaults);
10725
11160
  if (patch.logs !== void 0) runtime.store.updateLogs(patch.logs);
10726
- 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
+ }
10727
11165
  } catch (error) {
10728
11166
  if (error instanceof ConfigError) throw new DetailedError(error.message, {
10729
11167
  statusCode: 400,
@@ -11546,64 +11984,6 @@ var init_secret_parts = __esmMin((() => {
11546
11984
  EXTRA_FIELDS = { ddns: ["ddns", "ddnsKdf"] };
11547
11985
  }));
11548
11986
  //#endregion
11549
- //#region src/helpers/env-file.ts
11550
- /** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
11551
- function parseEnvFile(text) {
11552
- const env = {};
11553
- for (const raw of text.split("\n")) {
11554
- const line = raw.trim();
11555
- if (line.length === 0 || line.startsWith("#")) continue;
11556
- const assignment = line.startsWith("export ") ? line.slice(7) : line;
11557
- const separator = assignment.indexOf("=");
11558
- if (separator <= 0) continue;
11559
- const key = assignment.slice(0, separator).trim();
11560
- let value = assignment.slice(separator + 1).trim();
11561
- if (value.length > 1 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
11562
- env[key] = value;
11563
- }
11564
- return env;
11565
- }
11566
- /** Missing files are not an error: an env file is an optional override layer. */
11567
- function loadEnvFile(file) {
11568
- try {
11569
- return {
11570
- env: parseEnvFile(fs.readFileSync(file, "utf8")),
11571
- path: file,
11572
- error: null
11573
- };
11574
- } catch (error) {
11575
- if (error.code === "ENOENT") return {
11576
- env: {},
11577
- path: file,
11578
- error: null
11579
- };
11580
- return {
11581
- env: {},
11582
- path: file,
11583
- error: errorMessage(error)
11584
- };
11585
- }
11586
- }
11587
- /** Expands `${VAR}` from the given vars; unknown references are left visible. */
11588
- function expandEnv(value, vars) {
11589
- return value.replace(VARIABLE, (match, name) => vars[name] ?? match);
11590
- }
11591
- function expandEnvRecord(record, vars) {
11592
- return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, expandEnv(value, vars)]));
11593
- }
11594
- function expandEnvList(values, vars) {
11595
- return values.map((value) => expandEnv(value, vars));
11596
- }
11597
- function resolveEnvFilePath(file, cwd) {
11598
- if (path.isAbsolute(file)) return file;
11599
- return path.resolve(cwd, file);
11600
- }
11601
- var VARIABLE;
11602
- var init_env_file = __esmMin((() => {
11603
- init_error_message();
11604
- VARIABLE = /\$\{([A-Z_]\w*)\}/gi;
11605
- }));
11606
- //#endregion
11607
11987
  //#region src/providers/archive.ts
11608
11988
  /** Recognised by content, never by the file's name. */
11609
11989
  function isZipArchive(file) {
@@ -11850,18 +12230,99 @@ async function probeTcp(host, port, timeoutMs) {
11850
12230
  };
11851
12231
  }
11852
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
+ /**
11853
12319
  * Fetches the configured path and asserts the response, so "listening" is not
11854
12320
  * mistaken for "working".
11855
12321
  */
11856
12322
  async function probeHttp(host, port, options) {
11857
- const url = `http://${host}:${port}${options.path.startsWith("/") ? options.path : `/${options.path}`}`;
11858
12323
  const started = Date.now();
11859
12324
  try {
11860
- const response = await fetch(url, {
11861
- method: options.method,
11862
- redirect: "manual",
11863
- signal: AbortSignal.timeout(options.timeoutMs)
11864
- });
12325
+ const response = await request(new URL(`${options.scheme}://${host}:${port}${options.path.startsWith("/") ? options.path : `/${options.path}`}`), options.method, options.allowInvalidCert, options.timeoutMs);
11865
12326
  const ms = Date.now() - started;
11866
12327
  const expected = options.expectStatus ?? null;
11867
12328
  if (expected !== null && response.status !== expected) return {
@@ -11876,11 +12337,14 @@ async function probeHttp(host, port, options) {
11876
12337
  };
11877
12338
  const bodySkipped = options.expectBody.length > 0 && options.method === "HEAD";
11878
12339
  if (options.expectBody.length > 0 && !bodySkipped) {
11879
- if (!(await response.text()).includes(options.expectBody)) return {
11880
- healthy: false,
11881
- ms,
11882
- detail: `body does not contain ${JSON.stringify(options.expectBody)}`
11883
- };
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
+ }
11884
12348
  }
11885
12349
  if (bodySkipped) return {
11886
12350
  healthy: true,
@@ -11916,9 +12380,11 @@ async function probeHealth(options) {
11916
12380
  }
11917
12381
  return last;
11918
12382
  }
12383
+ var MAX_BODY_BYTES;
11919
12384
  var init_health_check = __esmMin((() => {
11920
12385
  init_port();
11921
12386
  init_error_message();
12387
+ MAX_BODY_BYTES = 1e6;
11922
12388
  }));
11923
12389
  //#endregion
11924
12390
  //#region src/providers/process.ts
@@ -13716,6 +14182,7 @@ var init_supervisor = __esmMin((() => {
13716
14182
  }
13717
14183
  /** The decisions that have to survive this panel, and nothing else. */
13718
14184
  persistEntryState(entry) {
14185
+ if (entry.removed) return;
13719
14186
  writeEntryState(entryStatePath(this.options.nannyDir, entry.config.id), {
13720
14187
  restarts: entry.restarts,
13721
14188
  bootstrapDone: entry.bootstrapDone,
@@ -14001,6 +14468,10 @@ function readText(file) {
14001
14468
  return null;
14002
14469
  }
14003
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
+ }
14004
14475
  /** The label of one item, for `applied`; items are built before actions run. */
14005
14476
  function labelOf(plan, id) {
14006
14477
  return plan.items.find((item) => item.id === id)?.label ?? id;
@@ -14048,13 +14519,14 @@ function readableWorkspaceSettings(text) {
14048
14519
  return false;
14049
14520
  }
14050
14521
  }
14051
- var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, SECRET_LABELS, BackupService;
14522
+ var MANIFEST, ALLOWED_ROOTS, SUFFIX, AUTO_PREFIX, RELOADABLE, SECRET_LABELS, BackupService;
14052
14523
  var init_backups = __esmMin((() => {
14053
14524
  init_migrations();
14054
14525
  init_parse();
14055
14526
  init_secret_parts();
14056
14527
  init_atomic();
14057
14528
  init_env_file();
14529
+ init_logger();
14058
14530
  init_paths();
14059
14531
  init_template();
14060
14532
  init_version();
@@ -14070,6 +14542,7 @@ var init_backups = __esmMin((() => {
14070
14542
  "data"
14071
14543
  ]);
14072
14544
  SUFFIX = ".zip";
14545
+ AUTO_PREFIX = "auto";
14073
14546
  RELOADABLE = /^global:settings$|^workspace:[^:]+:(?:settings|servers)$/;
14074
14547
  SECRET_LABELS = {
14075
14548
  password: {
@@ -14098,6 +14571,17 @@ var init_backups = __esmMin((() => {
14098
14571
  */
14099
14572
  flags = /* @__PURE__ */ new Map();
14100
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;
14101
14585
  constructor(options) {
14102
14586
  this.options = options;
14103
14587
  }
@@ -14105,6 +14589,9 @@ var init_backups = __esmMin((() => {
14105
14589
  async warm() {
14106
14590
  await this.refresh();
14107
14591
  }
14592
+ dispose() {
14593
+ this.disposed = true;
14594
+ }
14108
14595
  get directory() {
14109
14596
  return this.resolveDir();
14110
14597
  }
@@ -14116,6 +14603,8 @@ var init_backups = __esmMin((() => {
14116
14603
  enabled: config.enabled,
14117
14604
  dir: this.directory,
14118
14605
  keep: config.keep,
14606
+ schedule: { ...config.schedule },
14607
+ lastAutomaticAt: this.lastAutomatic(),
14119
14608
  includePaths: [...sources.includePaths],
14120
14609
  entries: this.entries(sources),
14121
14610
  files: this.list()
@@ -14141,7 +14630,13 @@ var init_backups = __esmMin((() => {
14141
14630
  const file = path.join(this.resolveDir(), name);
14142
14631
  return fs.existsSync(file) ? file : null;
14143
14632
  }
14144
- /** `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
+ */
14145
14640
  async create(options = {}) {
14146
14641
  if (!this.options.getConfig().enabled) return {
14147
14642
  ok: false,
@@ -14165,7 +14660,7 @@ var init_backups = __esmMin((() => {
14165
14660
  const sources = this.options.getSources();
14166
14661
  const staging = path.join(dir, `.staging-${Date.now()}`);
14167
14662
  const createdAt = Date.now();
14168
- 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}`;
14169
14664
  const destination = path.join(dir, name);
14170
14665
  try {
14171
14666
  fs.mkdirSync(staging, { recursive: true });
@@ -14239,7 +14734,7 @@ var init_backups = __esmMin((() => {
14239
14734
  createdAt,
14240
14735
  hostname: os.hostname(),
14241
14736
  writtenBy: appVersion(),
14242
- schema: 1,
14737
+ schema: 2,
14243
14738
  data
14244
14739
  };
14245
14740
  fs.writeFileSync(path.join(staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -14355,9 +14850,9 @@ var init_backups = __esmMin((() => {
14355
14850
  };
14356
14851
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
14357
14852
  plan.archive = manifestInfo(manifest);
14358
- if (manifest.schema !== void 0 && manifest.schema > 1) return {
14853
+ if (manifest.schema !== void 0 && manifest.schema > 2) return {
14359
14854
  ...plan,
14360
- error: `written by home-hosted ${manifest.writtenBy ?? "a newer release"} (config schema ${manifest.schema}); this release understands schema 1`
14855
+ error: `written by home-hosted ${manifest.writtenBy ?? "a newer release"} (config schema ${manifest.schema}); this release understands schema 2`
14361
14856
  };
14362
14857
  const sources = this.options.getSources();
14363
14858
  const selectedIds = options.include === void 0 ? null : new Set(options.include);
@@ -14943,9 +15438,46 @@ var init_backups = __esmMin((() => {
14943
15438
  });
14944
15439
  return true;
14945
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
+ */
14946
15449
  prune() {
14947
- const { keep } = this.options.getConfig();
14948
- 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
+ });
14949
15481
  }
14950
15482
  resolveDir() {
14951
15483
  const configured = this.options.getConfig().dir;
@@ -16193,10 +16725,11 @@ var init_log_files = __esmMin((() => {
16193
16725
  }));
16194
16726
  //#endregion
16195
16727
  //#region src/services/notifications.ts
16196
- var REASON_LABEL, TITLE, NotificationService;
16728
+ var REASON_LABEL, TITLE, COVERS, NotificationService;
16197
16729
  var init_notifications = __esmMin((() => {
16198
16730
  init_logger();
16199
16731
  init_telegram();
16732
+ init_webhook();
16200
16733
  init_error_message();
16201
16734
  REASON_LABEL = {
16202
16735
  "crash": "gave up restarting",
@@ -16220,122 +16753,230 @@ var init_notifications = __esmMin((() => {
16220
16753
  "ddns": "🌐 DNS updated",
16221
16754
  "ddns-error": "🌐 DNS update failed"
16222
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
+ };
16223
16767
  NotificationService = class {
16224
16768
  secrets;
16225
16769
  getConfig;
16226
16770
  cooldowns = /* @__PURE__ */ new Map();
16227
- lastResult = null;
16228
- lastResultAt = null;
16771
+ results = /* @__PURE__ */ new Map();
16229
16772
  constructor(secrets, getConfig) {
16230
16773
  this.secrets = secrets;
16231
16774
  this.getConfig = getConfig;
16232
16775
  }
16233
- get telegramTokenSet() {
16234
- return this.secrets.telegramTokenSet;
16776
+ /** The general block, as the page edits it. */
16777
+ defaults() {
16778
+ return { ...this.getConfig().defaults };
16235
16779
  }
16236
- status() {
16237
- const telegram = this.getConfig().telegram;
16238
- return {
16239
- enabled: telegram.enabled,
16240
- tokenSet: this.secrets.telegramTokenSet,
16241
- chatId: telegram.chatId,
16242
- onCrash: telegram.onCrash,
16243
- onUnhealthy: telegram.onUnhealthy,
16244
- onForcedRestart: telegram.onForcedRestart,
16245
- onRecovered: telegram.onRecovered,
16246
- onHost: telegram.onHost,
16247
- onDdns: telegram.onDdns,
16248
- cooldownMs: telegram.cooldownMs,
16249
- lastResult: this.lastResult,
16250
- 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
16251
16831
  };
16832
+ return { ...this.getConfig().defaults };
16833
+ }
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
+ });
16252
16849
  }
16253
- /** Enabled for this reason *and* outside its cooldown window. */
16850
+ /** Whether *any* destination would deliver this event. */
16254
16851
  shouldNotify(event, now = Date.now()) {
16255
- const telegram = this.getConfig().telegram;
16256
- if (!telegram.enabled) return false;
16257
- if (!{
16258
- "crash": telegram.onCrash,
16259
- "unhealthy": telegram.onUnhealthy,
16260
- "forced-restart": telegram.onForcedRestart,
16261
- "recovered": telegram.onRecovered,
16262
- "rss": telegram.onCrash,
16263
- "host": telegram.onHost,
16264
- "host-recovered": telegram.onHost,
16265
- "ddns": telegram.onDdns,
16266
- "ddns-error": telegram.onDdns
16267
- }[event.reason]) return false;
16268
- const until = this.cooldowns.get(`${event.serverId}:${event.reason}`) ?? 0;
16269
- return !(telegram.cooldownMs > 0 && until > now);
16270
- }
16271
- /** 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. */
16272
16855
  markSent(event, now = Date.now()) {
16273
- 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);
16274
16857
  }
16275
- /** 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. */
16276
16859
  notify(event) {
16277
16860
  this.dispatch(event).catch((error) => {
16278
16861
  logger.warn(`notification failed: ${errorMessage(error)}`);
16279
16862
  });
16280
16863
  }
16281
16864
  async dispatch(event) {
16282
- if (!this.shouldNotify(event)) return false;
16865
+ const targets = this.targetsFor(event);
16866
+ if (targets.length === 0) return false;
16283
16867
  this.markSent(event);
16284
- 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);
16871
+ }
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;
16285
16899
  }
16286
- /** Used by the "send test" button in settings. */
16287
- async sendTest(overrides = {}) {
16288
- const chatId = overrides.chatId ?? this.getConfig().telegram.chatId;
16289
- if (chatId.length === 0) return {
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 {
16290
16910
  ok: false,
16291
16911
  error: "no chat id configured"
16292
16912
  };
16293
- const token = this.resolveToken(overrides.botToken);
16913
+ const token = overrideToken !== void 0 && overrideToken.length > 0 ? overrideToken : this.secrets.telegramToken(id);
16294
16914
  if (token === null) return {
16295
16915
  ok: false,
16296
- error: "no bot token configured"
16916
+ error: "no bot token configured for this destination"
16917
+ };
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
16297
16931
  };
16298
- const result = await sendTelegramMessage(token, chatId, formatTelegramMessage("✅ home-hosted test", ["notifications are wired up correctly"]));
16299
- this.remember(result.ok ? "test message sent" : result.error ?? "test failed");
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");
16300
16940
  return result;
16301
16941
  }
16302
- async detectChats(overrides = {}) {
16303
- const token = this.resolveToken(overrides.botToken);
16304
- 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 {
16305
16946
  ok: false,
16306
16947
  chats: [],
16307
- error: "no bot token configured"
16948
+ error: "no bot token configured for this destination"
16308
16949
  };
16309
16950
  const result = await listTelegramChats(token);
16310
- 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");
16311
16952
  return result;
16312
16953
  }
16313
- /** Verifies a token without sending anything. */
16954
+ /** Verifies a token without storing or sending anything. */
16314
16955
  async verifyToken(token) {
16315
16956
  return verifyTelegramToken(token);
16316
16957
  }
16317
- resolveToken(tokenOverride) {
16318
- const token = tokenOverride?.trim() ?? this.secrets.telegramToken ?? "";
16319
- return token.length > 0 ? token : null;
16320
- }
16321
- async sendTelegram(html) {
16322
- const telegram = this.getConfig().telegram;
16323
- if (telegram.chatId.length === 0) {
16324
- this.remember("no chat id configured");
16325
- return false;
16326
- }
16327
- const token = this.resolveToken();
16328
- if (token === null) {
16329
- this.remember("no bot token configured");
16330
- return false;
16331
- }
16332
- const result = await sendTelegramMessage(token, telegram.chatId, html);
16333
- this.remember(result.ok ? "sent" : result.error ?? "send failed");
16334
- return result.ok;
16958
+ cooldownKey(target, event) {
16959
+ return `${target.id}:${event.serverId}:${event.reason}`;
16335
16960
  }
16336
- remember(message) {
16337
- this.lastResult = message;
16338
- 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
+ });
16339
16980
  }
16340
16981
  };
16341
16982
  }));
@@ -16374,8 +17015,7 @@ function stateSignature(state) {
16374
17015
  workspace.ddns.ipv6,
16375
17016
  ...workspace.ddns.records.map((record) => `${record.host}:${record.type}:${record.state}:${record.ip}`)
16376
17017
  ].join(":"),
16377
- workspace.notifications.telegram.enabled,
16378
- workspace.notifications.telegram.tokenSet,
17018
+ ...(workspace.notifications.destinations ?? []).map((destination) => `${destination.id}:${destination.kind}:${destination.enabled}:${destination.tokenSet}:${destination.lastResult ?? ""}`),
16379
17019
  ...workspace.servers.map((server) => [
16380
17020
  server.id,
16381
17021
  server.status,
@@ -16606,6 +17246,7 @@ var init_panel = __esmMin((() => {
16606
17246
  async dispose() {
16607
17247
  this.disposed = true;
16608
17248
  clearInterval(this.timer);
17249
+ this.options.backups.dispose();
16609
17250
  await Promise.all(this.workspaces().map((runtime) => runtime.supervisor.dispose()));
16610
17251
  for (const runtime of this.workspaces()) this.disposeRuntime(runtime);
16611
17252
  }
@@ -16719,6 +17360,7 @@ var init_panel = __esmMin((() => {
16719
17360
  if (this.disposed) return;
16720
17361
  const now = Date.now();
16721
17362
  await this.options.hostMonitor.tick(now);
17363
+ this.options.backups.tick(now);
16722
17364
  for (const runtime of this.workspaces()) runtime.ddns.tick(now);
16723
17365
  const proxy = this.options.proxy();
16724
17366
  if (proxy !== null) await proxy.sync().catch((error) => {
@@ -18561,7 +19203,10 @@ function targets(configOverride) {
18561
19203
  validate: (raw) => {
18562
19204
  let defaults = {};
18563
19205
  try {
18564
- 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 ?? {};
18565
19210
  } catch {}
18566
19211
  return parseServersFile(raw, defaults).errors[0] ?? null;
18567
19212
  }
@@ -18585,7 +19230,18 @@ async function runMigrate(config, dryRun, yes) {
18585
19230
  const from = typeof meta.schema === "number" ? meta.schema : 1;
18586
19231
  const plan = planConfigMigrations(from);
18587
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.`);
18588
- 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);
18589
19245
  if (problem !== null) fail(`${target.file} is not a config this release can read: ${problem}`);
18590
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}.
18591
19247
  That is a bug in this release: report it rather than editing the file.`);
@@ -18606,7 +19262,6 @@ async function runMigrate(config, dryRun, yes) {
18606
19262
  return;
18607
19263
  }
18608
19264
  }
18609
- const { config: migrated } = applyConfigMigrations(raw, from);
18610
19265
  const migratedProblem = target.validate(migrated);
18611
19266
  if (migratedProblem !== null) fail(`the migration produced a config this release cannot read:\n ${migratedProblem}`);
18612
19267
  fs.copyFileSync(target.file, `${target.file}.bak`);
@@ -18631,6 +19286,7 @@ var init_migrate = __esmMin((() => {
18631
19286
  init_paths();
18632
19287
  init_version();
18633
19288
  init_error_message();
19289
+ init_shape();
18634
19290
  migrateArgs = {
18635
19291
  config: {
18636
19292
  type: "string",