@camstack/system 1.2.436 → 1.2.438

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 (96) hide show
  1. package/dist/addon-runner.js +64 -12
  2. package/dist/addon-runner.mjs +65 -13
  3. package/dist/{agent-https-B8lKYVtU.mjs → agent-https-BPfHrI4c.mjs} +3 -3
  4. package/dist/auth/login-session-manager.d.ts +2 -0
  5. package/dist/auth/session-revocation-bus.d.ts +32 -0
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  9. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  10. package/dist/builtins/alerts/alerts.addon.js +7 -1
  11. package/dist/builtins/alerts/alerts.addon.mjs +7 -1
  12. package/dist/builtins/autotrack/index.js +1 -1
  13. package/dist/builtins/autotrack/index.mjs +1 -1
  14. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  15. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  16. package/dist/builtins/calendars/calendars.addon.js +1 -1
  17. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  18. package/dist/builtins/camera-grid/index.js +9 -1
  19. package/dist/builtins/camera-grid/index.mjs +9 -1
  20. package/dist/builtins/composer/composer.addon.js +2 -2
  21. package/dist/builtins/composer/composer.addon.mjs +2 -2
  22. package/dist/builtins/console-logging/index.js +12 -5
  23. package/dist/builtins/console-logging/index.mjs +12 -5
  24. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  25. package/dist/builtins/core-blocks/core-blocks.addon.mjs +5 -5
  26. package/dist/builtins/device-manager/device-manager.addon.d.ts +5 -0
  27. package/dist/builtins/device-manager/device-manager.addon.js +702 -28
  28. package/dist/builtins/device-manager/device-manager.addon.mjs +702 -28
  29. package/dist/builtins/device-manager/device-meta-types.d.ts +4 -0
  30. package/dist/builtins/device-manager/device-provider-context.d.ts +9 -0
  31. package/dist/builtins/device-manager/device-removal-hooks.d.ts +103 -0
  32. package/dist/builtins/device-manager/device-row-store.d.ts +1 -0
  33. package/dist/builtins/device-manager/device-tombstone-store.d.ts +58 -0
  34. package/dist/builtins/device-manager/removal-cleanup.d.ts +50 -0
  35. package/dist/builtins/hub-forwarder/index.js +1 -1
  36. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  37. package/dist/builtins/keychain/index.js +37 -4
  38. package/dist/builtins/keychain/index.mjs +37 -4
  39. package/dist/builtins/keychain/keychain-service.d.ts +14 -0
  40. package/dist/builtins/keychain/keychain-store.d.ts +1 -1
  41. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  42. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  43. package/dist/builtins/local-auth/local-auth.addon.js +53 -12
  44. package/dist/builtins/local-auth/local-auth.addon.mjs +53 -13
  45. package/dist/builtins/local-network/local-network.addon.js +10 -1
  46. package/dist/builtins/local-network/local-network.addon.mjs +13 -4
  47. package/dist/builtins/loki-logging/index.js +28 -5
  48. package/dist/builtins/loki-logging/index.mjs +28 -5
  49. package/dist/builtins/native-metrics/native-metrics.addon.js +11 -1
  50. package/dist/builtins/native-metrics/native-metrics.addon.mjs +20 -10
  51. package/dist/builtins/platform-probe/index.js +1 -1
  52. package/dist/builtins/platform-probe/index.mjs +8 -8
  53. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  54. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  55. package/dist/builtins/snapshot/index.js +11 -1
  56. package/dist/builtins/snapshot/index.mjs +11 -1
  57. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +4 -1
  58. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +4 -1
  59. package/dist/builtins/sqlite-storage/index.js +1 -1
  60. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  61. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +3 -3
  62. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +3 -3
  63. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  64. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  65. package/dist/builtins/system-config/system-config.addon.d.ts +9 -0
  66. package/dist/builtins/system-config/system-config.addon.js +135 -72
  67. package/dist/builtins/system-config/system-config.addon.mjs +135 -73
  68. package/dist/builtins/winston-logging/index.js +14 -5
  69. package/dist/builtins/winston-logging/index.mjs +14 -5
  70. package/dist/{composition-sources-BAOlCu4j.js → composition-sources-DI3Z83vG.js} +1 -1
  71. package/dist/{composition-sources-gN_BWUKp.mjs → composition-sources-DpHGCjdL.mjs} +1 -1
  72. package/dist/{constants-BFpowodq.mjs → constants-DQK705Sb.mjs} +1 -1
  73. package/dist/{constants-GENKqk9k.js → constants-wkcn5Ij3.js} +1 -1
  74. package/dist/{device-ca-CuWuRW9N.js → device-ca-BSvkXqyI.js} +1 -1
  75. package/dist/{device-ca-BNM5ai4t.mjs → device-ca-DvIZjXIl.mjs} +1 -1
  76. package/dist/{dist-DjD2M79P.mjs → dist-Dc8GKC5H.mjs} +301 -6
  77. package/dist/{dist-DVe0Nsp8.js → dist-GgG-7amw.js} +348 -5
  78. package/dist/file-host-worker.js +1 -1
  79. package/dist/file-host-worker.mjs +1 -1
  80. package/dist/{hub-mdns-ciao-DJRwtlA8.mjs → hub-mdns-ciao-DMlQTdPU.mjs} +1 -1
  81. package/dist/i18n/catalog-registry.d.ts +35 -0
  82. package/dist/i18n/index.d.ts +2 -0
  83. package/dist/index.d.ts +3 -0
  84. package/dist/index.js +666 -21
  85. package/dist/index.mjs +676 -38
  86. package/dist/kernel/gc-census.d.ts +18 -1
  87. package/dist/kernel/settings-view-types.d.ts +2 -0
  88. package/dist/kernel/transport/local-transport.d.ts +3 -0
  89. package/dist/kernel/transport/peer-answered-error.d.ts +5 -1
  90. package/dist/{restore-archive-D4h904rb.js → restore-archive-CTRz8v9o.js} +1 -1
  91. package/dist/{restore-archive-BxmP1ChO.mjs → restore-archive-DPg9duVY.mjs} +1 -1
  92. package/dist/{retired-settings-keys-CYP5IZJ5.js → retired-settings-keys-Dr_4lfKu.js} +1 -1
  93. package/dist/{retired-settings-keys-CQaknvx1.mjs → retired-settings-keys-Is0zO8gX.mjs} +1 -1
  94. package/dist/{wal-maintenance-CaDsrJM_.mjs → wal-maintenance-Bx3lcVA7.mjs} +0 -0
  95. package/dist/{wal-maintenance-Br60onAQ.js → wal-maintenance-mjB2i-VR.js} +0 -0
  96. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- import { A as FieldClaimModeSchema, Ar as WELL_KNOWN_TAB_MAP, Bt as expandContainer, Dt as defineExtensionSlotProvider, Gr as isDeviceConfigCap, Ir as collectZodSecretPaths, It as enumerateItemArrayFields, Jr as isSecretClear, Kt as formatMinutes, Lt as enumerateSchemaFields, Mt as deviceManagerCapability, Nt as deviceStateCapability, O as DeviceStatusSchema, Pt as deviceStatusCapability, R as MINUTES_PER_DAY, Rn as runtimeStatePolicyFor, Sn as parseStreamParamsFormPatch, St as createDeviceExtensionProvider, Un as siteZoneKey, W as STREAM_PROFILE_META, Xr as isSecretMarkerLike, Yr as isSecretMarker, _r as DeviceFeature, br as errMsg, c as CAMERA_SWITCH_ORDER, cn as locationIconKey, di as EventCategory, ft as canonicalIconKey, ii as resolveSecretMarkers, in as legacyAutoLinkIds, j as FieldClaimSchema, jt as deviceExtensionCapability, kr as SECRET_CLEAR, li as toSecretFieldView, lt as buildStreamParamsConfigSchema, oi as secretTreeFromPaths, qr as isKeychainStoredField, r as ALL_CAPABILITY_DEFINITIONS, si as sleep$1, u as CAP_NAMES_WITH_STATUS, ui as transformSecretWrite, vr as DeviceRole, x as DAY_NAMES, xr as BaseAddon, yn as normalizeUnit, yr as DeviceType } from "../../dist-DjD2M79P.mjs";
2
- import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-CQaknvx1.mjs";
1
+ import { Bt as enumerateItemArrayFields, C as DAY_NAMES, Dn as parseStreamParamsFormPatch, Dr as errMsg, Er as DeviceType, Et as createDeviceExtensionProvider, Ft as deviceExtensionCapability, Ir as SECRET_CLEAR, It as deviceManagerCapability, Lr as WELL_KNOWN_TAB_MAP, Lt as deviceStateCapability, N as FieldClaimModeSchema, Or as BaseAddon, P as FieldClaimSchema, Qr as isDeviceConfigCap, Rt as deviceStatusCapability, Tr as DeviceRole, Un as runtimeStatePolicyFor, Ur as collectZodSecretPaths, V as MINUTES_PER_DAY, Vt as enumerateSchemaFields, Wt as expandContainer, Xt as formatMinutes, Yn as siteZoneKey, _i as transformSecretWrite, c as CAMERA_SWITCH_ORDER, di as resolveSecretMarkers, ei as isKeychainStoredField, gi as toSecretFieldView, gt as canonicalIconKey, j as DeviceStatusSchema, jt as defineExtensionSlotProvider, ln as legacyAutoLinkIds, mi as sleep$1, ni as isSecretMarker, pi as secretTreeFromPaths, pn as locationIconKey, pt as buildStreamParamsConfigSchema, q as STREAM_PROFILE_META, r as ALL_CAPABILITY_DEFINITIONS, ri as isSecretMarkerLike, ti as isSecretClear, u as CAP_NAMES_WITH_STATUS, vi as userError, wn as normalizeUnit, wr as DeviceFeature, yi as EventCategory } from "../../dist-Dc8GKC5H.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-Is0zO8gX.mjs";
3
3
  import { z } from "zod";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
@@ -964,7 +964,9 @@ function toWireShape(input) {
964
964
  const out = { sections: input.sections.map((s) => ({
965
965
  id: s.id,
966
966
  title: s.title,
967
+ ...s.titleKey !== void 0 ? { titleKey: s.titleKey } : {},
967
968
  ...s.description !== void 0 ? { description: s.description } : {},
969
+ ...s.descriptionKey !== void 0 ? { descriptionKey: s.descriptionKey } : {},
968
970
  ...s.style !== void 0 ? { style: s.style } : {},
969
971
  ...s.defaultCollapsed !== void 0 ? { defaultCollapsed: s.defaultCollapsed } : {},
970
972
  ...s.columns !== void 0 ? { columns: s.columns } : {},
@@ -1775,6 +1777,7 @@ function buildDayNightConfigSchema(options, status) {
1775
1777
  type: "select",
1776
1778
  key: "dayNight_mode",
1777
1779
  label: "Mode",
1780
+ labelKey: "server:dayNightConfig.dayNight_mode.label",
1778
1781
  options: options.modes.map((m) => ({
1779
1782
  value: m,
1780
1783
  label: MODE_LABELS[m]
@@ -1785,6 +1788,7 @@ function buildDayNightConfigSchema(options, status) {
1785
1788
  type: "slider",
1786
1789
  key: "dayNight_sensitivity",
1787
1790
  label: "IR-cut sensitivity",
1791
+ labelKey: "server:dayNightConfig.dayNight_sensitivity.label",
1788
1792
  min: options.sensitivity.min,
1789
1793
  max: options.sensitivity.max,
1790
1794
  step: options.sensitivity.step,
@@ -1795,6 +1799,7 @@ function buildDayNightConfigSchema(options, status) {
1795
1799
  type: "number",
1796
1800
  key: "dayNight_switchDelaySec",
1797
1801
  label: "Switch delay",
1802
+ labelKey: "server:dayNightConfig.dayNight_switchDelaySec.label",
1798
1803
  unit: "s",
1799
1804
  min: options.switchDelaySec.min,
1800
1805
  max: options.switchDelaySec.max,
@@ -1806,7 +1811,9 @@ function buildDayNightConfigSchema(options, status) {
1806
1811
  id: "day-night",
1807
1812
  tab: "image",
1808
1813
  title: "Day / Night",
1814
+ titleKey: "server:section.s_day-night.title",
1809
1815
  description: "IR-cut switching mode and the photocell knobs that gate it.",
1816
+ descriptionKey: "server:section.s_day-night.description",
1810
1817
  columns: 2,
1811
1818
  fields
1812
1819
  }] };
@@ -1899,6 +1906,7 @@ function buildImageSettingsConfigSchema(options, status) {
1899
1906
  type: "slider",
1900
1907
  key: "imageSettings_brightness",
1901
1908
  label: "Brightness",
1909
+ labelKey: "server:imageSettingsConfig.imageSettings_brightness.label",
1902
1910
  min: options.brightness.min,
1903
1911
  max: options.brightness.max,
1904
1912
  step: options.brightness.step,
@@ -1909,6 +1917,7 @@ function buildImageSettingsConfigSchema(options, status) {
1909
1917
  type: "slider",
1910
1918
  key: "imageSettings_contrast",
1911
1919
  label: "Contrast",
1920
+ labelKey: "server:imageSettingsConfig.imageSettings_contrast.label",
1912
1921
  min: options.contrast.min,
1913
1922
  max: options.contrast.max,
1914
1923
  step: options.contrast.step,
@@ -1919,6 +1928,7 @@ function buildImageSettingsConfigSchema(options, status) {
1919
1928
  type: "slider",
1920
1929
  key: "imageSettings_saturation",
1921
1930
  label: "Saturation",
1931
+ labelKey: "server:imageSettingsConfig.imageSettings_saturation.label",
1922
1932
  min: options.saturation.min,
1923
1933
  max: options.saturation.max,
1924
1934
  step: options.saturation.step,
@@ -1929,6 +1939,7 @@ function buildImageSettingsConfigSchema(options, status) {
1929
1939
  type: "slider",
1930
1940
  key: "imageSettings_sharpness",
1931
1941
  label: "Sharpness",
1942
+ labelKey: "server:imageSettingsConfig.imageSettings_sharpness.label",
1932
1943
  min: options.sharpness.min,
1933
1944
  max: options.sharpness.max,
1934
1945
  step: options.sharpness.step,
@@ -1939,6 +1950,7 @@ function buildImageSettingsConfigSchema(options, status) {
1939
1950
  type: "boolean",
1940
1951
  key: "imageSettings_mirror",
1941
1952
  label: "Mirror",
1953
+ labelKey: "server:imageSettingsConfig.imageSettings_mirror.label",
1942
1954
  style: "switch",
1943
1955
  default: status?.mirror ?? false
1944
1956
  });
@@ -1946,6 +1958,7 @@ function buildImageSettingsConfigSchema(options, status) {
1946
1958
  type: "boolean",
1947
1959
  key: "imageSettings_flip",
1948
1960
  label: "Flip",
1961
+ labelKey: "server:imageSettingsConfig.imageSettings_flip.label",
1949
1962
  style: "switch",
1950
1963
  default: status?.flip ?? false
1951
1964
  });
@@ -1953,6 +1966,7 @@ function buildImageSettingsConfigSchema(options, status) {
1953
1966
  type: "select",
1954
1967
  key: "imageSettings_rotate",
1955
1968
  label: "Rotation",
1969
+ labelKey: "server:imageSettingsConfig.imageSettings_rotate.label",
1956
1970
  options: options.rotateOptions.map((r) => ({
1957
1971
  value: r,
1958
1972
  label: ROTATE_LABELS[r]
@@ -1963,6 +1977,7 @@ function buildImageSettingsConfigSchema(options, status) {
1963
1977
  type: "select",
1964
1978
  key: "imageSettings_whiteBalance",
1965
1979
  label: "White balance",
1980
+ labelKey: "server:imageSettingsConfig.imageSettings_whiteBalance.label",
1966
1981
  options: options.whiteBalanceModes.map((m) => ({
1967
1982
  value: m,
1968
1983
  label: WHITE_BALANCE_LABELS[m]
@@ -1973,6 +1988,7 @@ function buildImageSettingsConfigSchema(options, status) {
1973
1988
  type: "slider",
1974
1989
  key: "imageSettings_warmth",
1975
1990
  label: "Warmth",
1991
+ labelKey: "server:imageSettingsConfig.imageSettings_warmth.label",
1976
1992
  min: options.warmth.min,
1977
1993
  max: options.warmth.max,
1978
1994
  step: options.warmth.step,
@@ -1987,6 +2003,7 @@ function buildImageSettingsConfigSchema(options, status) {
1987
2003
  type: "select",
1988
2004
  key: "imageSettings_exposureMode",
1989
2005
  label: "Exposure mode",
2006
+ labelKey: "server:imageSettingsConfig.imageSettings_exposureMode.label",
1990
2007
  options: options.exposureModes.map((m) => ({
1991
2008
  value: m,
1992
2009
  label: EXPOSURE_LABELS[m]
@@ -1997,6 +2014,7 @@ function buildImageSettingsConfigSchema(options, status) {
1997
2014
  type: "select",
1998
2015
  key: "imageSettings_backlightMode",
1999
2016
  label: "Backlight compensation",
2017
+ labelKey: "server:imageSettingsConfig.imageSettings_backlightMode.label",
2000
2018
  options: options.backlightModes.map((m) => ({
2001
2019
  value: m,
2002
2020
  label: BACKLIGHT_LABELS[m]
@@ -2008,7 +2026,9 @@ function buildImageSettingsConfigSchema(options, status) {
2008
2026
  id: "image-settings",
2009
2027
  tab: "image",
2010
2028
  title: "Image adjustment",
2029
+ titleKey: "server:section.s_image-settings.title",
2011
2030
  description: "Picture sliders, orientation and exposure. Option lists are read live from the camera — fields the firmware doesn't expose are hidden.",
2031
+ descriptionKey: "server:section.s_image-settings.description",
2012
2032
  columns: 2,
2013
2033
  fields
2014
2034
  }] };
@@ -2142,26 +2162,31 @@ function volumesField(status) {
2142
2162
  {
2143
2163
  key: "id",
2144
2164
  label: "Id",
2165
+ labelKey: "server:volumesField.id.label",
2145
2166
  kind: "monospace",
2146
2167
  width: "70px"
2147
2168
  },
2148
2169
  {
2149
2170
  key: "label",
2150
2171
  label: "Name",
2172
+ labelKey: "server:volumesField.label.label",
2151
2173
  kind: "monospace"
2152
2174
  },
2153
2175
  {
2154
2176
  key: "status",
2155
2177
  label: "Status",
2178
+ labelKey: "server:volumesField.status.label",
2156
2179
  kind: "status"
2157
2180
  },
2158
2181
  {
2159
2182
  key: "capacity",
2160
- label: "Capacity"
2183
+ label: "Capacity",
2184
+ labelKey: "server:volumesField.capacity.label"
2161
2185
  },
2162
2186
  {
2163
2187
  key: "free",
2164
- label: "Free"
2188
+ label: "Free",
2189
+ labelKey: "server:volumesField.free.label"
2165
2190
  }
2166
2191
  ],
2167
2192
  value: rows,
@@ -2185,27 +2210,32 @@ function tracksField(status) {
2185
2210
  {
2186
2211
  key: "id",
2187
2212
  label: "Track",
2213
+ labelKey: "server:tracksField.id.label",
2188
2214
  kind: "monospace",
2189
2215
  width: "80px"
2190
2216
  },
2191
2217
  {
2192
2218
  key: "primary",
2193
2219
  label: "Primary",
2220
+ labelKey: "server:tracksField.primary.label",
2194
2221
  width: "80px"
2195
2222
  },
2196
2223
  {
2197
2224
  key: "enabled",
2198
2225
  label: "Enabled",
2226
+ labelKey: "server:tracksField.enabled.label",
2199
2227
  kind: "status",
2200
2228
  width: "90px"
2201
2229
  },
2202
2230
  {
2203
2231
  key: "codec",
2204
- label: "Codec"
2232
+ label: "Codec",
2233
+ labelKey: "server:tracksField.codec.label"
2205
2234
  },
2206
2235
  {
2207
2236
  key: "resolution",
2208
- label: "Resolution"
2237
+ label: "Resolution",
2238
+ labelKey: "server:tracksField.resolution.label"
2209
2239
  }
2210
2240
  ],
2211
2241
  value: rows,
@@ -2407,7 +2437,9 @@ function buildRecordingOnboardConfigSchema(options, status) {
2407
2437
  id: "recording-onboard-storage",
2408
2438
  tab: "recording",
2409
2439
  title: "Camera storage",
2440
+ titleKey: "server:storageSection.s_recording-onboard-storage.title",
2410
2441
  description: "The card in the camera, as the camera itself describes it.",
2442
+ descriptionKey: "server:storageSection.s_recording-onboard-storage.description",
2411
2443
  columns: 1,
2412
2444
  fields: [
2413
2445
  storageBanner(status),
@@ -2418,7 +2450,9 @@ function buildRecordingOnboardConfigSchema(options, status) {
2418
2450
  id: "recording-onboard-recording",
2419
2451
  tab: "recording",
2420
2452
  title: "Camera recording",
2453
+ titleKey: "server:recordingSection.s_recording-onboard-recording.title",
2421
2454
  description: "What the camera records to its own card. This writes the camera, not CamStack's recorder.",
2455
+ descriptionKey: "server:recordingSection.s_recording-onboard-recording.description",
2422
2456
  columns: 2,
2423
2457
  fields: [
2424
2458
  booleanField(RECORDING_ONBOARD_KEYS.enabled, "Record to card", "The camera-side master switch.", options.enabled, status?.enabled ?? null),
@@ -3027,7 +3061,7 @@ async function getStreamSources(pctx, input) {
3027
3061
  }));
3028
3062
  }
3029
3063
  }
3030
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3064
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw userError("device.not-found", { id: deviceId }, `[device-manager] Device with id ${deviceId} not found`);
3031
3065
  return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
3032
3066
  }
3033
3067
  async function getConfigSchema(pctx, input) {
@@ -3040,7 +3074,7 @@ async function getConfigSchema(pctx, input) {
3040
3074
  ...entry.description !== void 0 ? { description: entry.description } : {}
3041
3075
  }));
3042
3076
  }
3043
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3077
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw userError("device.not-found", { id: deviceId }, `[device-manager] Device with id ${deviceId} not found`);
3044
3078
  return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
3045
3079
  }
3046
3080
  async function getSettingsSchema(pctx, input) {
@@ -3061,7 +3095,7 @@ async function updateConfig(pctx, input) {
3061
3095
  return { success: true };
3062
3096
  }
3063
3097
  }
3064
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3098
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw userError("device.not-found", { id: deviceId }, `[device-manager] Device with id ${deviceId} not found`);
3065
3099
  await pctx.requireDeviceOps(deviceId).setConfig({
3066
3100
  deviceId,
3067
3101
  values: input.values
@@ -3161,7 +3195,7 @@ async function setStreamProfileMap(pctx, input) {
3161
3195
  return { success: true };
3162
3196
  }
3163
3197
  }
3164
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3198
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw userError("device.not-found", { id: deviceId }, `[device-manager] Device with id ${deviceId} not found`);
3165
3199
  await pctx.requireDeviceOps(deviceId).setConfig({
3166
3200
  deviceId,
3167
3201
  values: { _profileMap: input.profileMap }
@@ -3198,7 +3232,7 @@ async function probeStreams(pctx, input) {
3198
3232
  }
3199
3233
  async function discoverDevices(pctx, input) {
3200
3234
  const dp = await pctx.host.requireDeviceProvider(input.addonId);
3201
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
3235
+ if (!await dp.supportsDiscovery({})) throw userError("system.addon-no-discovery", { addonId: String(input.addonId) }, `Addon "${input.addonId}" does not support device discovery`);
3202
3236
  return (await dp.discoverDevices({})).map((d) => ({
3203
3237
  stableId: d.stableId,
3204
3238
  type: d.type,
@@ -3211,7 +3245,7 @@ async function discoverDevices(pctx, input) {
3211
3245
  }
3212
3246
  async function adoptDevice(pctx, input) {
3213
3247
  const dp = await pctx.host.requireDeviceProvider(input.addonId);
3214
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
3248
+ if (!await dp.supportsDiscovery({})) throw userError("system.addon-no-adoption", { addonId: String(input.addonId) }, `Addon "${input.addonId}" does not support device adoption`);
3215
3249
  const summary = await pctx.removalFence.runUnfenced(() => dp.adoptDiscoveredDevice({ candidate: input.candidate }));
3216
3250
  if (input.integrationId !== void 0) try {
3217
3251
  await pctx.stampIntegrationId(summary.id, input.integrationId);
@@ -3233,7 +3267,7 @@ async function getCreationSchema(pctx, input) {
3233
3267
  }
3234
3268
  async function createDevice(pctx, input) {
3235
3269
  const dp = await pctx.host.requireDeviceProvider(input.addonId);
3236
- if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
3270
+ if (!await dp.supportsManualCreation({})) throw userError("system.addon-no-manual-create", { addonId: String(input.addonId) }, `Addon "${input.addonId}" does not support manual device creation`);
3237
3271
  const summary = await pctx.removalFence.runUnfenced(() => dp.createDevice({
3238
3272
  type: input.type,
3239
3273
  config: input.config
@@ -3326,7 +3360,7 @@ async function testField(pctx, input) {
3326
3360
  let owningAddonId = null;
3327
3361
  if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
3328
3362
  if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
3329
- if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
3363
+ if (!owningAddonId) throw userError("device.not-found", { id: deviceId }, `Device with id ${deviceId} not found`);
3330
3364
  const dp = await pctx.host.waitDeviceProvider(owningAddonId);
3331
3365
  if (!dp) return {
3332
3366
  status: "ok",
@@ -3835,12 +3869,14 @@ async function buildBaseDeviceSection(deps, deviceId) {
3835
3869
  } else if (driverResult.status === "unavailable") sections.push({
3836
3870
  id: "driver-settings-unavailable",
3837
3871
  title: "Driver Settings",
3872
+ titleKey: "server:baseDeviceSection.s_driver-settings-unavailable.title",
3838
3873
  tab: "general",
3839
3874
  order: 1,
3840
3875
  fields: [{
3841
3876
  type: "info",
3842
3877
  key: "driver-settings-unavailable-notice",
3843
3878
  label: "Temporarily unavailable",
3879
+ labelKey: "server:baseDeviceSection.s_driver-settings-unavailable.driver-settings-unavailable-notice.label",
3844
3880
  content: "Driver settings are temporarily unavailable — the addon may be restarting. Refresh the page in a moment.",
3845
3881
  variant: "warning"
3846
3882
  }]
@@ -5541,6 +5577,250 @@ async function runLocationBackfillAtBoot(pctx, bootRows) {
5541
5577
  });
5542
5578
  }
5543
5579
  //#endregion
5580
+ //#region src/builtins/device-manager/device-removal-hooks.ts
5581
+ /**
5582
+ * device-manager's side of `device-removal-hook` (D865): tell every implementer
5583
+ * a device was deleted, and answer the admin question "which stored data belongs
5584
+ * to devices that no longer exist?".
5585
+ *
5586
+ * Two entry points, one rule: **an implementer is told to delete only on a
5587
+ * recorded delete (a tombstone), or on an explicit operator confirm.** Nothing
5588
+ * here infers a deletion from a device being absent from a single read.
5589
+ */
5590
+ /** The capability name implementers register under. */
5591
+ var DEVICE_REMOVAL_HOOK_CAP = "device-removal-hook";
5592
+ async function hookProviders(api) {
5593
+ return (await api.addons.listCapabilityProviders.query({ capName: DEVICE_REMOVAL_HOOK_CAP })).map((p) => p.addonId);
5594
+ }
5595
+ /**
5596
+ * The removal step that calls every implementer. Each implementer is its own
5597
+ * nested step, so a failing one is retried by the bounded reconcile without
5598
+ * re-calling the others. Failing to ENUMERATE the implementers throws, and the
5599
+ * outer cleanup retries the whole step.
5600
+ */
5601
+ function removalHookStep(deps, input) {
5602
+ return {
5603
+ store: "removal-hooks",
5604
+ run: async () => {
5605
+ const api = deps.api;
5606
+ if (api === void 0) {
5607
+ deps.logger.warn("removal hooks not called — no api on this context", { tags: { deviceId: input.deviceId } });
5608
+ return 0;
5609
+ }
5610
+ const addonIds = await hookProviders(api);
5611
+ if (addonIds.length === 0) return 0;
5612
+ const steps = addonIds.map((addonId) => ({
5613
+ store: `hook:${addonId}`,
5614
+ run: async () => {
5615
+ const result = await api.deviceRemovalHook.onDeviceRemoved.mutate({
5616
+ ...input,
5617
+ addonId
5618
+ });
5619
+ return Object.values(result.counts).reduce((sum, n) => sum + n, 0);
5620
+ }
5621
+ }));
5622
+ const report = await deps.cleanup.run(input.deviceId, steps);
5623
+ return Object.values(report.counts).reduce((sum, n) => sum + n, 0);
5624
+ }
5625
+ };
5626
+ }
5627
+ /**
5628
+ * The ids device-manager knows, from TWO consistent reads (D49). Refuses when a
5629
+ * read throws, answers empty (an empty fleet and a failed read look the same),
5630
+ * or returns fewer ids than the ledger has rows (a malformed row is skipped by
5631
+ * the decoder, and a device skipped is a device whose data would be deleted).
5632
+ * The known set is the UNION of both reads: an id is absent only when BOTH
5633
+ * reads lack it.
5634
+ */
5635
+ async function checkFleetTwice(reads) {
5636
+ const known = /* @__PURE__ */ new Set();
5637
+ for (const attempt of [1, 2]) {
5638
+ let read;
5639
+ try {
5640
+ read = await reads.readFleet();
5641
+ } catch (err) {
5642
+ return {
5643
+ ok: false,
5644
+ reason: `device list read ${attempt} failed: ${errMsg(err)}`
5645
+ };
5646
+ }
5647
+ if (read.ids.length === 0) return {
5648
+ ok: false,
5649
+ reason: `device list read ${attempt} answered empty`
5650
+ };
5651
+ if (read.ids.length !== read.rowCount) return {
5652
+ ok: false,
5653
+ reason: `device list read ${attempt} returned ${read.ids.length} of ${read.rowCount} rows`
5654
+ };
5655
+ for (const id of read.ids) known.add(id);
5656
+ }
5657
+ return {
5658
+ ok: true,
5659
+ known
5660
+ };
5661
+ }
5662
+ /** Stores holding data for devices that are not in `known`, grouped per device. */
5663
+ function orphanDevices(held, known) {
5664
+ const byDevice = /* @__PURE__ */ new Map();
5665
+ const holders = /* @__PURE__ */ new Set();
5666
+ let overlap = 0;
5667
+ for (const [, entry] of held) if (!holders.has(entry.deviceId)) {
5668
+ holders.add(entry.deviceId);
5669
+ if (known.has(entry.deviceId)) overlap += 1;
5670
+ }
5671
+ for (const [addonId, entry] of held) {
5672
+ if (known.has(entry.deviceId) || entry.count === 0) continue;
5673
+ const stores = byDevice.get(entry.deviceId) ?? {};
5674
+ stores[`${addonId}:${entry.store}`] = (stores[`${addonId}:${entry.store}`] ?? 0) + entry.count;
5675
+ byDevice.set(entry.deviceId, stores);
5676
+ }
5677
+ const idSpaceMismatch = holders.size >= 5 && overlap === 0;
5678
+ return {
5679
+ devices: [...byDevice.entries()].toSorted(([a], [b]) => a - b).map(([deviceId, stores]) => ({
5680
+ deviceId,
5681
+ stores
5682
+ })),
5683
+ idSpaceMismatch
5684
+ };
5685
+ }
5686
+ /** The admin "Clean data of deleted devices" action: dry run unless `confirm`. */
5687
+ async function cleanupDeletedDevicesData(deps, input) {
5688
+ const skipped = (reason, unreachable = []) => {
5689
+ deps.logger.warn("cleanup of deleted devices data skipped — nothing listed or deleted", { meta: { reason } });
5690
+ return {
5691
+ status: "skipped",
5692
+ reason,
5693
+ devices: [],
5694
+ unreachable: [...unreachable]
5695
+ };
5696
+ };
5697
+ const api = deps.api;
5698
+ if (api === void 0) return skipped("no api on this context");
5699
+ const fleet = await checkFleetTwice(deps.reads);
5700
+ if (!fleet.ok) return skipped(fleet.reason);
5701
+ let addonIds;
5702
+ try {
5703
+ addonIds = await hookProviders(api);
5704
+ } catch (err) {
5705
+ return skipped(`implementers could not be listed: ${errMsg(err)}`);
5706
+ }
5707
+ const unreachable = /* @__PURE__ */ new Set();
5708
+ const held = [];
5709
+ for (const addonId of addonIds) try {
5710
+ for (const entry of await api.deviceRemovalHook.listDeviceData.query({ addonId })) held.push([addonId, entry]);
5711
+ } catch (err) {
5712
+ unreachable.add(addonId);
5713
+ deps.logger.warn("cleanup of deleted devices data: an implementer did not answer", { meta: {
5714
+ addonId,
5715
+ error: errMsg(err)
5716
+ } });
5717
+ }
5718
+ if (deps.settings !== void 0) try {
5719
+ for (const scope of await deps.settings.list()) held.push(["settings", {
5720
+ store: scope.addonId,
5721
+ deviceId: scope.deviceId,
5722
+ count: 1
5723
+ }]);
5724
+ } catch (err) {
5725
+ return skipped(`device settings could not be listed: ${errMsg(err)}`, [...unreachable]);
5726
+ }
5727
+ const { devices, idSpaceMismatch } = orphanDevices(held, fleet.known);
5728
+ if (idSpaceMismatch) return skipped("none of the device ids holding data is a known device — the id space looks wrong, refusing", [...unreachable]);
5729
+ if (!input.confirm) {
5730
+ deps.logger.info("cleanup of deleted devices data: dry run", { meta: {
5731
+ devices: devices.length,
5732
+ unreachable: [...unreachable]
5733
+ } });
5734
+ return {
5735
+ status: "dry-run",
5736
+ reason: null,
5737
+ devices: [...devices],
5738
+ unreachable: [...unreachable]
5739
+ };
5740
+ }
5741
+ const now = (deps.now ?? (() => Date.now()))();
5742
+ const cleaned = [];
5743
+ for (const device of devices) {
5744
+ const deleted = {};
5745
+ for (const addonId of addonIds) try {
5746
+ const result = await api.deviceRemovalHook.onDeviceRemoved.mutate({
5747
+ addonId,
5748
+ deviceId: device.deviceId,
5749
+ deletedAt: now,
5750
+ providerAddonId: "unknown"
5751
+ });
5752
+ for (const [store, count] of Object.entries(result.counts)) deleted[`${addonId}:${store}`] = count;
5753
+ } catch (err) {
5754
+ deps.logger.warn("cleanup of deleted devices data: an implementer failed", {
5755
+ tags: { deviceId: device.deviceId },
5756
+ meta: {
5757
+ addonId,
5758
+ error: errMsg(err)
5759
+ }
5760
+ });
5761
+ }
5762
+ if (deps.settings !== void 0 && Object.keys(device.stores).some((k) => k.startsWith("settings:"))) try {
5763
+ deleted["settings"] = await deps.settings.clear(device.deviceId);
5764
+ } catch (err) {
5765
+ deps.logger.warn("cleanup of deleted devices data: settings not cleared", {
5766
+ tags: { deviceId: device.deviceId },
5767
+ meta: { error: errMsg(err) }
5768
+ });
5769
+ }
5770
+ deps.logger.info("cleaned data of a device deleted before tombstones", {
5771
+ tags: { deviceId: device.deviceId },
5772
+ meta: { counts: deleted }
5773
+ });
5774
+ cleaned.push({
5775
+ deviceId: device.deviceId,
5776
+ stores: deleted
5777
+ });
5778
+ }
5779
+ return {
5780
+ status: "cleaned",
5781
+ reason: null,
5782
+ devices: cleaned,
5783
+ unreachable: [...unreachable]
5784
+ };
5785
+ }
5786
+ /** The settings-store collection every addon's per-device settings share. */
5787
+ var ADDON_DEVICE_SETTINGS_COLLECTION = "addon-device-settings";
5788
+ /** The engine's hard ceiling on one read; a result this long may be a truncated one. */
5789
+ var SCOPE_READ_LIMIT = 2e4;
5790
+ /** Scope access over the settings store; undefined when the store is unavailable. */
5791
+ function settingsScopes(store) {
5792
+ if (store === void 0) return void 0;
5793
+ return {
5794
+ list: async () => {
5795
+ const rows = await store.query.query({
5796
+ collection: ADDON_DEVICE_SETTINGS_COLLECTION,
5797
+ filter: { limit: SCOPE_READ_LIMIT }
5798
+ });
5799
+ if (rows.length >= SCOPE_READ_LIMIT) throw new Error(`the settings read returned ${rows.length} rows — possibly truncated`);
5800
+ const seen = /* @__PURE__ */ new Map();
5801
+ for (const row of rows) {
5802
+ const addonId = row.data["addonId"];
5803
+ const raw = row.data["deviceId"];
5804
+ if (typeof addonId !== "string" || typeof raw !== "string") continue;
5805
+ const deviceId = Number(raw);
5806
+ if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
5807
+ seen.set(`${addonId}:${deviceId}`, {
5808
+ deviceId,
5809
+ addonId
5810
+ });
5811
+ }
5812
+ return [...seen.values()];
5813
+ },
5814
+ clear: async (deviceId) => {
5815
+ const { deleted } = await store.deleteWhere.mutate({
5816
+ collection: ADDON_DEVICE_SETTINGS_COLLECTION,
5817
+ filter: { where: { deviceId: String(deviceId) } }
5818
+ });
5819
+ return deleted;
5820
+ }
5821
+ };
5822
+ }
5823
+ //#endregion
5544
5824
  //#region src/builtins/device-manager/device-meta-actions.ts
5545
5825
  /**
5546
5826
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -5939,6 +6219,91 @@ async function registerDevice(pctx, input) {
5939
6219
  }
5940
6220
  });
5941
6221
  }
6222
+ /**
6223
+ * The stores device-manager (or the keychain, for secrets) keeps for a device.
6224
+ * Another addon's own per-device data is cleaned by that addon, off
6225
+ * `DeviceUnregistered` — the kernel never reaches into its tables (D864).
6226
+ */
6227
+ function removalSteps(pctx, deviceId, providerAddonId, deletedAt) {
6228
+ const secrets = pctx.host.ctx.secrets;
6229
+ return [
6230
+ {
6231
+ store: "tombstone",
6232
+ run: async () => {
6233
+ await pctx.tombstones.record({
6234
+ deviceId,
6235
+ deletedAt,
6236
+ providerAddonId
6237
+ });
6238
+ return 1;
6239
+ }
6240
+ },
6241
+ {
6242
+ store: "keychain-secrets",
6243
+ run: async () => {
6244
+ const purge = secrets?.deleteDeviceScope;
6245
+ if (purge === void 0) {
6246
+ pctx.host.ctx.logger.warn("device secrets not purged — no keychain door", { tags: { deviceId } });
6247
+ return 0;
6248
+ }
6249
+ return (await purge(deviceId)).length;
6250
+ }
6251
+ },
6252
+ {
6253
+ store: "device-config",
6254
+ run: async () => {
6255
+ const stored = await pctx.settings.readDeviceStore(deviceId);
6256
+ await pctx.settings.clearDeviceStore(deviceId);
6257
+ return Object.keys(stored).length;
6258
+ }
6259
+ },
6260
+ {
6261
+ store: "addon-device-settings",
6262
+ run: async () => {
6263
+ const api = pctx.host.ctx.api?.settingsStore;
6264
+ if (api === void 0) throw new Error("settings-store api not available");
6265
+ const { deleted } = await api.deleteWhere.mutate({
6266
+ collection: "addon-device-settings",
6267
+ filter: { where: { deviceId: String(deviceId) } }
6268
+ });
6269
+ return deleted;
6270
+ }
6271
+ },
6272
+ {
6273
+ store: "runtime-state",
6274
+ run: async () => {
6275
+ await pctx.host.forgetMirrorDevice(deviceId);
6276
+ const stored = await pctx.settings.readDeviceRuntimeState(deviceId);
6277
+ await pctx.settings.clearDeviceRuntimeState(deviceId);
6278
+ return Object.keys(stored).length;
6279
+ }
6280
+ },
6281
+ {
6282
+ store: "field-claims",
6283
+ run: async () => {
6284
+ await pctx.host.fieldClaims.pruneDevice(deviceId, pctx.settings);
6285
+ return 0;
6286
+ }
6287
+ },
6288
+ {
6289
+ store: "bindings",
6290
+ run: () => pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
6291
+ const key = String(deviceId);
6292
+ const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
6293
+ const slice = bindingsStore.deviceBindings[key];
6294
+ if (!slice) return 0;
6295
+ const { [key]: _removed, ...rest } = bindingsStore.deviceBindings;
6296
+ await writeBindingsStore(pctx.bindingsDeps, { deviceBindings: rest });
6297
+ return Object.keys(slice).length;
6298
+ })
6299
+ },
6300
+ removalHookStep(pctx.removalHooks, {
6301
+ deviceId,
6302
+ deletedAt,
6303
+ providerAddonId
6304
+ })
6305
+ ];
6306
+ }
5942
6307
  async function removeDevice(pctx, input) {
5943
6308
  const { deviceId } = input;
5944
6309
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
@@ -5954,17 +6319,7 @@ async function removeDevice(pctx, input) {
5954
6319
  });
5955
6320
  });
5956
6321
  pctx.secretConfigPaths.delete(deviceId);
5957
- await pctx.settings.clearDeviceStore(deviceId);
5958
- await pctx.host.forgetMirrorDevice(deviceId);
5959
- await pctx.settings.clearDeviceRuntimeState(deviceId);
5960
- await pctx.host.fieldClaims.pruneDevice(deviceId, pctx.settings);
5961
- const bindingKey = String(deviceId);
5962
- await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
5963
- const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
5964
- if (!bindingsStore.deviceBindings[bindingKey]) return;
5965
- const { [bindingKey]: _removedBindings, ...restBindings } = bindingsStore.deviceBindings;
5966
- await writeBindingsStore(pctx.bindingsDeps, { deviceBindings: restBindings });
5967
- });
6322
+ await pctx.removalCleanup.run(deviceId, removalSteps(pctx, deviceId, addonId, Date.now()));
5968
6323
  pctx.host.remoteNativeCaps.delete(deviceId);
5969
6324
  pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(deviceId);
5970
6325
  pctx.metaStore.idToAddonId.delete(deviceId);
@@ -6661,7 +7016,7 @@ async function listLocations(pctx) {
6661
7016
  */
6662
7017
  async function addLocation(pctx, input) {
6663
7018
  const trimmed = input.name.trim();
6664
- if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
7019
+ if (trimmed.length === 0) throw userError("system.location-name-required", void 0, "[device-manager] addLocation: name must be non-empty");
6665
7020
  await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
6666
7021
  const current = (await pctx.metaStore.readStore()).locations ?? [];
6667
7022
  if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
@@ -6707,7 +7062,7 @@ async function listLocationIcons(pctx) {
6707
7062
  */
6708
7063
  async function setLocationIcon(pctx, input) {
6709
7064
  const trimmed = input.location.trim();
6710
- if (trimmed.length === 0) throw new Error("[device-manager] setLocationIcon: location must be non-empty");
7065
+ if (trimmed.length === 0) throw userError("system.location-required", void 0, "[device-manager] setLocationIcon: location must be non-empty");
6711
7066
  const key = locationIconKey(trimmed);
6712
7067
  await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
6713
7068
  const icons = (await pctx.metaStore.readStore()).locationIcons ?? {};
@@ -6807,7 +7162,7 @@ async function listSiteZones(pctx) {
6807
7162
  */
6808
7163
  async function setSiteZone(pctx, input) {
6809
7164
  const label = input.name.trim();
6810
- if (label.length === 0) throw new Error("[device-manager] setSiteZone: name must be non-empty");
7165
+ if (label.length === 0) throw userError("system.site-zone-name-required", void 0, "[device-manager] setSiteZone: name must be non-empty");
6811
7166
  const key = siteZoneKey(label);
6812
7167
  await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
6813
7168
  const zones = (await pctx.metaStore.readStore()).siteZones ?? {};
@@ -8294,6 +8649,171 @@ var DeviceRowStore = class {
8294
8649
  }
8295
8650
  };
8296
8651
  //#endregion
8652
+ //#region src/builtins/device-manager/device-tombstone-store.ts
8653
+ /**
8654
+ * The tombstones of deleted devices (D865).
8655
+ *
8656
+ * A device DELETE is a fact other addons must be able to learn reliably, long
8657
+ * after the `DeviceUnregistered` event (telemetry, droppable — D8/D11) is gone.
8658
+ * device-manager is the only authority on whether a device exists, so the record
8659
+ * of a deletion lives here, in its own collection, beside the device ledger.
8660
+ *
8661
+ * ## What a tombstone is NOT
8662
+ *
8663
+ * It is not "the device is unregistered / offline / its provider is restarting".
8664
+ * Those are states of a device that still exists; only {@link record}'s one
8665
+ * caller — `removeDevice`, the single door every delete converges on — writes a
8666
+ * tombstone. Anything that reconciles from tombstones therefore can never
8667
+ * destroy data of a device that merely went quiet.
8668
+ *
8669
+ * ## Expiry
8670
+ *
8671
+ * {@link DEVICE_TOMBSTONE_RETENTION_MS} (90 days). A tombstone older than that
8672
+ * is deleted the next time one is written or read. Ninety days is generous on
8673
+ * purpose: an addon that stayed disabled for a quarter still learns of the
8674
+ * deletions on its return. Data of a device deleted longer ago than that is
8675
+ * reachable only by the admin "clean data of deleted devices" action.
8676
+ */
8677
+ /**
8678
+ * @durable class=ledger owner=device-manager
8679
+ * write="`removeDevice` records one row per deleted device (key = device id) after the
8680
+ * device row is gone"
8681
+ * retention="90 days (`DEVICE_TOMBSTONE_RETENTION_MS`), pruned whenever a tombstone is
8682
+ * written or the list is read. Bounded by the deletions an operator makes in a quarter."
8683
+ */
8684
+ var DEVICE_TOMBSTONES_COLLECTION = "device-manager:tombstones";
8685
+ var DEVICE_TOMBSTONE_COLUMNS = [
8686
+ {
8687
+ name: "id",
8688
+ type: "TEXT",
8689
+ primaryKey: true,
8690
+ notNull: true
8691
+ },
8692
+ {
8693
+ name: "deviceId",
8694
+ type: "INTEGER",
8695
+ notNull: true
8696
+ },
8697
+ {
8698
+ name: "deletedAt",
8699
+ type: "INTEGER",
8700
+ notNull: true
8701
+ },
8702
+ {
8703
+ name: "providerAddonId",
8704
+ type: "TEXT",
8705
+ notNull: true
8706
+ }
8707
+ ];
8708
+ var DEVICE_TOMBSTONE_INDEXES = [{
8709
+ name: "idx_dm_tombstones_deleted_at",
8710
+ columns: ["deletedAt"]
8711
+ }];
8712
+ /** More than the engine's default row cap, so a read is never silently truncated. */
8713
+ var TOMBSTONE_READ_LIMIT = 2e4;
8714
+ function decodeTombstone(raw) {
8715
+ const deviceId = raw["deviceId"];
8716
+ const deletedAt = raw["deletedAt"];
8717
+ const providerAddonId = raw["providerAddonId"];
8718
+ if (typeof deviceId !== "number" || !Number.isInteger(deviceId) || deviceId <= 0) return null;
8719
+ if (typeof deletedAt !== "number" || typeof providerAddonId !== "string") return null;
8720
+ return {
8721
+ deviceId,
8722
+ deletedAt,
8723
+ providerAddonId
8724
+ };
8725
+ }
8726
+ var DeviceTombstoneStore = class {
8727
+ backend;
8728
+ logger;
8729
+ now;
8730
+ retentionMs;
8731
+ declared = null;
8732
+ constructor(opts) {
8733
+ this.backend = opts.backend;
8734
+ this.logger = opts.logger;
8735
+ this.now = opts.now ?? (() => Date.now());
8736
+ this.retentionMs = opts.retentionMs ?? 7776e6;
8737
+ }
8738
+ /** Lazy, idempotent declaration; a rejection is not latched. */
8739
+ declare() {
8740
+ if (this.declared !== null) return this.declared;
8741
+ const run = this.backend.declareCollection({
8742
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8743
+ columns: DEVICE_TOMBSTONE_COLUMNS,
8744
+ indexes: DEVICE_TOMBSTONE_INDEXES
8745
+ }).catch((err) => {
8746
+ this.declared = null;
8747
+ throw err;
8748
+ });
8749
+ this.declared = run;
8750
+ return run;
8751
+ }
8752
+ /** Record a deletion. Re-recording the same id keeps the EARLIER `deletedAt`. */
8753
+ async record(tombstone) {
8754
+ await this.declare();
8755
+ const key = String(tombstone.deviceId);
8756
+ const existing = await this.backend.get({
8757
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8758
+ key
8759
+ });
8760
+ if ((isPlainObject(existing) ? decodeTombstone(existing) : null) === null) await this.backend.set({
8761
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8762
+ key,
8763
+ value: {
8764
+ deviceId: tombstone.deviceId,
8765
+ deletedAt: tombstone.deletedAt,
8766
+ providerAddonId: tombstone.providerAddonId
8767
+ }
8768
+ });
8769
+ await this.pruneExpired();
8770
+ }
8771
+ /** Tombstones with `deletedAt >= sinceMs`, oldest first. Expired ones are pruned first. */
8772
+ async listSince(sinceMs) {
8773
+ await this.declare();
8774
+ await this.pruneExpired();
8775
+ const floor = Math.max(sinceMs, this.now() - this.retentionMs);
8776
+ const rows = await this.backend.query({
8777
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8778
+ filter: {
8779
+ whereBetween: { deletedAt: [floor, Number.MAX_SAFE_INTEGER] },
8780
+ orderBy: {
8781
+ field: "deletedAt",
8782
+ direction: "asc"
8783
+ },
8784
+ limit: TOMBSTONE_READ_LIMIT
8785
+ }
8786
+ });
8787
+ const out = [];
8788
+ for (const row of rows) {
8789
+ const decoded = decodeTombstone(row.data);
8790
+ if (decoded !== null) out.push(decoded);
8791
+ }
8792
+ return out;
8793
+ }
8794
+ /** Delete tombstones older than the retention window. Best-effort: a failure is logged. */
8795
+ async pruneExpired() {
8796
+ try {
8797
+ const cutoff = this.now() - this.retentionMs;
8798
+ const stale = await this.backend.query({
8799
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8800
+ filter: {
8801
+ whereBetween: { deletedAt: [0, cutoff - 1] },
8802
+ limit: TOMBSTONE_READ_LIMIT
8803
+ }
8804
+ });
8805
+ for (const row of stale) await this.backend.delete({
8806
+ collection: DEVICE_TOMBSTONES_COLLECTION,
8807
+ key: row.id
8808
+ });
8809
+ return stale.length;
8810
+ } catch (err) {
8811
+ this.logger.warn("device tombstone prune failed — retried on the next write", { meta: { error: errMsg(err) } });
8812
+ return 0;
8813
+ }
8814
+ }
8815
+ };
8816
+ //#endregion
8297
8817
  //#region src/builtins/device-manager/device-state-claims.ts
8298
8818
  function createDeviceStateClaimMethods(deps) {
8299
8819
  const { mirror, index, settings, logger } = deps;
@@ -9760,6 +10280,119 @@ function createMigrationGuard(now = Date.now) {
9760
10280
  }
9761
10281
  };
9762
10282
  }
10283
+ //#endregion
10284
+ //#region src/builtins/device-manager/removal-cleanup.ts
10285
+ /**
10286
+ * What a device removal leaves behind is cleaned AT REMOVAL, by steps that
10287
+ * cannot block one another (D864).
10288
+ *
10289
+ * `removeDevice` used to run its clean-up as one straight line: the first step
10290
+ * that threw skipped every later one, and the keychain was not in the line at
10291
+ * all — a device-scoped secret (`secret://provider-dreame/device/4483/…`)
10292
+ * outlived its device for good. Each store is a {@link RemovalStep} now: run
10293
+ * independently, counted, logged ONCE per device with `tags: { deviceId }`,
10294
+ * and a step that failed is retried by a BOUNDED in-memory reconcile
10295
+ * (a few attempts, growing delays, then it gives up loudly).
10296
+ *
10297
+ * There is deliberately no boot-time sweep (the operator forbids purges on
10298
+ * boot): a retry that dies with the process is picked up by the keychain
10299
+ * admin's orphan listing (System -> Secrets), which names a device-scoped
10300
+ * secret whose device no longer points at it.
10301
+ */
10302
+ /** Retry delays after a failed step; the list length bounds the attempts. */
10303
+ var REMOVAL_RETRY_DELAYS_MS = [
10304
+ 3e4,
10305
+ 12e4,
10306
+ 6e5
10307
+ ];
10308
+ async function attempt(steps) {
10309
+ const counts = {};
10310
+ const failed = [];
10311
+ const errors = {};
10312
+ for (const step of steps) try {
10313
+ counts[step.store] = await step.run();
10314
+ } catch (err) {
10315
+ failed.push(step);
10316
+ errors[step.store] = errMsg(err);
10317
+ }
10318
+ return {
10319
+ counts,
10320
+ failed,
10321
+ errors
10322
+ };
10323
+ }
10324
+ function createRemovalCleanup(opts) {
10325
+ const label = opts.label ?? "device removal cleanup";
10326
+ const delays = opts.retryDelaysMs ?? REMOVAL_RETRY_DELAYS_MS;
10327
+ const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
10328
+ const clearTimer = opts.clearTimer ?? ((handle) => clearTimeout(handle));
10329
+ const pending = /* @__PURE__ */ new Set();
10330
+ let disposed = false;
10331
+ const schedule = (deviceId, steps, retryIndex, first) => {
10332
+ if (disposed) return;
10333
+ const delay = delays[retryIndex];
10334
+ if (delay === void 0) {
10335
+ opts.logger.error(`${label} gave up — the data stays until cleaned by hand`, {
10336
+ tags: { deviceId },
10337
+ meta: {
10338
+ stores: steps.map((s) => s.store),
10339
+ attempts: retryIndex + (first ? 1 : 0)
10340
+ }
10341
+ });
10342
+ return;
10343
+ }
10344
+ const handle = setTimer(() => {
10345
+ pending.delete(handle);
10346
+ retry(deviceId, steps, retryIndex);
10347
+ }, delay);
10348
+ handle.unref?.();
10349
+ pending.add(handle);
10350
+ };
10351
+ const retry = async (deviceId, steps, retryIndex) => {
10352
+ const result = await attempt(steps);
10353
+ opts.logger.info(`${label} retried`, {
10354
+ tags: { deviceId },
10355
+ meta: {
10356
+ counts: result.counts,
10357
+ failed: result.failed.map((s) => s.store),
10358
+ retryIndex
10359
+ }
10360
+ });
10361
+ if (result.failed.length > 0) schedule(deviceId, result.failed, retryIndex + 1, false);
10362
+ };
10363
+ return {
10364
+ async run(deviceId, steps) {
10365
+ const result = await attempt(steps);
10366
+ const failedStores = result.failed.map((s) => s.store);
10367
+ const meta = {
10368
+ counts: result.counts,
10369
+ failed: failedStores,
10370
+ errors: result.errors
10371
+ };
10372
+ if (failedStores.length === 0) opts.logger.info(label, {
10373
+ tags: { deviceId },
10374
+ meta
10375
+ });
10376
+ else {
10377
+ opts.logger.warn(`${label} incomplete — failed steps are retried`, {
10378
+ tags: { deviceId },
10379
+ meta
10380
+ });
10381
+ schedule(deviceId, result.failed, 0, true);
10382
+ }
10383
+ return {
10384
+ deviceId,
10385
+ counts: result.counts,
10386
+ failed: failedStores
10387
+ };
10388
+ },
10389
+ dispose() {
10390
+ disposed = true;
10391
+ for (const handle of pending) clearTimer(handle);
10392
+ pending.clear();
10393
+ }
10394
+ };
10395
+ }
9763
10396
  function pairKey(addonId, stableId) {
9764
10397
  return `${addonId}${stableId}`;
9765
10398
  }
@@ -9882,6 +10515,9 @@ var DeviceManagerAddon = class extends BaseAddon {
9882
10515
  * `allocateDeviceId` never consults.
9883
10516
  */
9884
10517
  removalFence = createRemovalFence();
10518
+ /** Store-by-store clean-up of a removed device, with a bounded retry (D864). */
10519
+ removalCleanupImpl = null;
10520
+ removalHooksCleanupImpl = null;
9885
10521
  /** Declared secret config paths per device, as last registered (D783). */
9886
10522
  secretConfigPaths = createSecretConfigPathRegistry();
9887
10523
  /** The keychain side of driver device forms (D789), wired in onInitialize. */
@@ -10093,6 +10729,10 @@ var DeviceManagerAddon = class extends BaseAddon {
10093
10729
  const deviceRows = new DeviceRowStore(deviceRowBackendOf(settingsStoreApi), this.ctx.logger.child("rows"));
10094
10730
  await deviceRows.declare();
10095
10731
  this.deviceRows = deviceRows;
10732
+ const tombstones = new DeviceTombstoneStore({
10733
+ backend: deviceRowBackendOf(settingsStoreApi),
10734
+ logger: this.ctx.logger.child("tombstones")
10735
+ });
10096
10736
  this.staleWrapperActivations.checkOnce(() => readBindingsStore(this.bindingsDeps));
10097
10737
  try {
10098
10738
  await purgeRetiredSettingsRows(deviceRows.retiredRowStore(), this.ctx.logger.child("RetiredRows"));
@@ -10128,6 +10768,13 @@ var DeviceManagerAddon = class extends BaseAddon {
10128
10768
  bindingsDeps: this.bindingsDeps,
10129
10769
  migrationGuard: createMigrationGuard(),
10130
10770
  removalFence: this.removalFence,
10771
+ removalCleanup: this.removalCleanup,
10772
+ tombstones,
10773
+ removalHooks: {
10774
+ api: this.ctx.api,
10775
+ logger: this.ctx.logger,
10776
+ cleanup: this.removalHooksCleanup
10777
+ },
10131
10778
  secretConfigPaths: this.secretConfigPaths,
10132
10779
  requireDeviceOps,
10133
10780
  stampIntegrationId: stampIntegrationId$1,
@@ -10161,6 +10808,18 @@ var DeviceManagerAddon = class extends BaseAddon {
10161
10808
  migrateDevice: (input) => migrateDevice(pctx, input),
10162
10809
  registerDevice: (input) => registerDevice(pctx, input),
10163
10810
  removeDevice: (input) => removeDevice(pctx, input),
10811
+ listDeletedSince: ({ sinceMs }) => tombstones.listSince(sinceMs),
10812
+ cleanupDeletedDevicesData: (input) => cleanupDeletedDevicesData({
10813
+ api: this.ctx.api,
10814
+ logger: this.ctx.logger,
10815
+ settings: settingsScopes(this.ctx.api?.settingsStore),
10816
+ reads: { readFleet: async () => {
10817
+ return {
10818
+ ids: (await deviceRows.listAll()).map((r) => r.meta.id),
10819
+ rowCount: await deviceRows.count()
10820
+ };
10821
+ } }
10822
+ }, input),
10164
10823
  persistConfig: (input) => persistConfig(pctx, input),
10165
10824
  loadConfig: (input) => loadConfig(pctx, input),
10166
10825
  loadMeta: (input) => loadMeta(pctx, input),
@@ -10384,7 +11043,22 @@ var DeviceManagerAddon = class extends BaseAddon {
10384
11043
  }
10385
11044
  ];
10386
11045
  }
11046
+ get removalCleanup() {
11047
+ if (this.removalCleanupImpl === null) this.removalCleanupImpl = createRemovalCleanup({ logger: this.ctx.logger });
11048
+ return this.removalCleanupImpl;
11049
+ }
11050
+ get removalHooksCleanup() {
11051
+ if (this.removalHooksCleanupImpl === null) this.removalHooksCleanupImpl = createRemovalCleanup({
11052
+ logger: this.ctx.logger,
11053
+ label: "device removal hooks"
11054
+ });
11055
+ return this.removalHooksCleanupImpl;
11056
+ }
10387
11057
  async onShutdown() {
11058
+ this.removalCleanupImpl?.dispose();
11059
+ this.removalCleanupImpl = null;
11060
+ this.removalHooksCleanupImpl?.dispose();
11061
+ this.removalHooksCleanupImpl = null;
10388
11062
  this.propagator?.stop();
10389
11063
  this.propagator = null;
10390
11064
  this.uninstallStatusOverlay?.();