@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
@@ -25,7 +25,11 @@ export interface ContributionShape {
25
25
  sections: Array<{
26
26
  id: string;
27
27
  title: string;
28
+ /** `"<namespace>:<key>"` of the translated `title` (D863). */
29
+ titleKey?: string;
28
30
  description?: string;
31
+ /** `"<namespace>:<key>"` of the translated `description` (D863). */
32
+ descriptionKey?: string;
29
33
  style?: "card" | "accordion";
30
34
  defaultCollapsed?: boolean;
31
35
  columns?: 1 | 2 | 3 | 4;
@@ -20,6 +20,9 @@ import type { DeviceManagerSettings, DeviceMetaStore } from "./device-meta-store
20
20
  import type { FieldClaimsIndex } from "./field-claims-index.js";
21
21
  import type { OwnedCaps } from "./field-ownership.js";
22
22
  import type { MigrationGuard } from "./migrate-guard.js";
23
+ import type { DeviceTombstoneStore } from "./device-tombstone-store.js";
24
+ import type { RemovalHookDeps } from "./device-removal-hooks.js";
25
+ import type { RemovalCleanup } from "./removal-cleanup.js";
23
26
  import type { RemovalFence } from "./removal-fence.js";
24
27
  import type { SecretConfigPathRegistry } from "./secret-config-paths.js";
25
28
  import type { SeedMode } from "./device-state-mirror.js";
@@ -101,6 +104,12 @@ export interface ProviderContext {
101
104
  * tears down, which is how a deleted camera came back as a config-less
102
105
  * shell (D376). */
103
106
  readonly removalFence: RemovalFence;
107
+ /** Cleans a removed device's data store by store, bounded retry on failure (D864). */
108
+ readonly removalCleanup: RemovalCleanup;
109
+ /** The tombstone of every deleted device — the reliable record other addons reconcile from (D865). */
110
+ readonly tombstones: DeviceTombstoneStore;
111
+ /** Tells every `device-removal-hook` implementer a device was deleted (D865). */
112
+ readonly removalHooks: RemovalHookDeps;
104
113
  /** The secret config paths each device declared at `registerDevice` (D783). */
105
114
  readonly secretConfigPaths: SecretConfigPathRegistry;
106
115
  /** Resolve the per-device `device-ops` native provider (throws when absent). */
@@ -0,0 +1,103 @@
1
+ /**
2
+ * device-manager's side of `device-removal-hook` (D865): tell every implementer
3
+ * a device was deleted, and answer the admin question "which stored data belongs
4
+ * to devices that no longer exist?".
5
+ *
6
+ * Two entry points, one rule: **an implementer is told to delete only on a
7
+ * recorded delete (a tombstone), or on an explicit operator confirm.** Nothing
8
+ * here infers a deletion from a device being absent from a single read.
9
+ */
10
+ import { type CleanupDeletedDevicesResult, type DeviceDataEntry, type DeviceRemovedInput, type DeviceRemovedResult, type IScopedLogger, type OrphanDeviceData, type SettingsStoreClient } from "@camstack/types";
11
+ import type { RemovalCleanup, RemovalStep } from "./removal-cleanup.js";
12
+ /** The capability name implementers register under. */
13
+ export declare const DEVICE_REMOVAL_HOOK_CAP = "device-removal-hook";
14
+ /** The slice of `ctx.api` this module uses — a pin is the `addonId` selector. */
15
+ export interface RemovalHookApi {
16
+ readonly addons: {
17
+ readonly listCapabilityProviders: {
18
+ query(input: {
19
+ capName: string;
20
+ }): Promise<readonly {
21
+ readonly addonId: string;
22
+ }[]>;
23
+ };
24
+ };
25
+ readonly deviceRemovalHook: {
26
+ readonly onDeviceRemoved: {
27
+ mutate(input: DeviceRemovedInput & {
28
+ addonId: string;
29
+ }): Promise<DeviceRemovedResult>;
30
+ };
31
+ readonly listDeviceData: {
32
+ query(input: {
33
+ addonId: string;
34
+ }): Promise<readonly DeviceDataEntry[]>;
35
+ };
36
+ };
37
+ }
38
+ export interface RemovalHookDeps {
39
+ readonly api: RemovalHookApi | undefined;
40
+ readonly logger: IScopedLogger;
41
+ readonly cleanup: RemovalCleanup;
42
+ }
43
+ /**
44
+ * The removal step that calls every implementer. Each implementer is its own
45
+ * nested step, so a failing one is retried by the bounded reconcile without
46
+ * re-calling the others. Failing to ENUMERATE the implementers throws, and the
47
+ * outer cleanup retries the whole step.
48
+ */
49
+ export declare function removalHookStep(deps: RemovalHookDeps, input: DeviceRemovedInput): RemovalStep;
50
+ /** Why a cleanup refuses to list or delete anything. */
51
+ export type AbsenceCheck = {
52
+ readonly ok: true;
53
+ readonly known: ReadonlySet<number>;
54
+ } | {
55
+ readonly ok: false;
56
+ readonly reason: string;
57
+ };
58
+ export interface AbsenceReads {
59
+ /** One read of the device ledger: its ids and the row count the engine reports. */
60
+ readonly readFleet: () => Promise<{
61
+ readonly ids: readonly number[];
62
+ readonly rowCount: number;
63
+ }>;
64
+ }
65
+ /**
66
+ * The ids device-manager knows, from TWO consistent reads (D49). Refuses when a
67
+ * read throws, answers empty (an empty fleet and a failed read look the same),
68
+ * or returns fewer ids than the ledger has rows (a malformed row is skipped by
69
+ * the decoder, and a device skipped is a device whose data would be deleted).
70
+ * The known set is the UNION of both reads: an id is absent only when BOTH
71
+ * reads lack it.
72
+ */
73
+ export declare function checkFleetTwice(reads: AbsenceReads): Promise<AbsenceCheck>;
74
+ /** Stores holding data for devices that are not in `known`, grouped per device. */
75
+ export declare function orphanDevices(held: readonly (readonly [string, DeviceDataEntry])[], known: ReadonlySet<number>): {
76
+ readonly devices: readonly OrphanDeviceData[];
77
+ readonly idSpaceMismatch: boolean;
78
+ };
79
+ /** Every addon's per-device settings scopes, read and cleared centrally (D865). */
80
+ export interface SettingsScopeAccess {
81
+ /** One entry per (device, addon) holding settings; throws on a failed or truncated read. */
82
+ list(): Promise<readonly {
83
+ readonly deviceId: number;
84
+ readonly addonId: string;
85
+ }[]>;
86
+ /** Delete every addon's scope of one device; resolves to the rows deleted. */
87
+ clear(deviceId: number): Promise<number>;
88
+ }
89
+ export interface CleanupDeps {
90
+ readonly api: RemovalHookApi | undefined;
91
+ readonly settings: SettingsScopeAccess | undefined;
92
+ readonly logger: IScopedLogger;
93
+ readonly reads: AbsenceReads;
94
+ readonly now?: () => number;
95
+ }
96
+ /** The admin "Clean data of deleted devices" action: dry run unless `confirm`. */
97
+ export declare function cleanupDeletedDevicesData(deps: CleanupDeps, input: {
98
+ readonly confirm: boolean;
99
+ }): Promise<CleanupDeletedDevicesResult>;
100
+ /** The settings-store collection every addon's per-device settings share. */
101
+ export declare const ADDON_DEVICE_SETTINGS_COLLECTION = "addon-device-settings";
102
+ /** Scope access over the settings store; undefined when the store is unavailable. */
103
+ export declare function settingsScopes(store: Pick<SettingsStoreClient, "query" | "deleteWhere"> | undefined): SettingsScopeAccess | undefined;
@@ -238,6 +238,7 @@ export interface DeviceRow {
238
238
  /** Has `registerDevice` ever run for this device? (former `deviceIndex` membership) */
239
239
  readonly registered: boolean;
240
240
  }
241
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
241
242
  /**
242
243
  * Decode one stored row.
243
244
  *
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The tombstones of deleted devices (D865).
3
+ *
4
+ * A device DELETE is a fact other addons must be able to learn reliably, long
5
+ * after the `DeviceUnregistered` event (telemetry, droppable — D8/D11) is gone.
6
+ * device-manager is the only authority on whether a device exists, so the record
7
+ * of a deletion lives here, in its own collection, beside the device ledger.
8
+ *
9
+ * ## What a tombstone is NOT
10
+ *
11
+ * It is not "the device is unregistered / offline / its provider is restarting".
12
+ * Those are states of a device that still exists; only {@link record}'s one
13
+ * caller — `removeDevice`, the single door every delete converges on — writes a
14
+ * tombstone. Anything that reconciles from tombstones therefore can never
15
+ * destroy data of a device that merely went quiet.
16
+ *
17
+ * ## Expiry
18
+ *
19
+ * {@link DEVICE_TOMBSTONE_RETENTION_MS} (90 days). A tombstone older than that
20
+ * is deleted the next time one is written or read. Ninety days is generous on
21
+ * purpose: an addon that stayed disabled for a quarter still learns of the
22
+ * deletions on its return. Data of a device deleted longer ago than that is
23
+ * reachable only by the admin "clean data of deleted devices" action.
24
+ */
25
+ import { type CollectionColumn, type CollectionIndex, type DeviceTombstone, type IScopedLogger } from "@camstack/types";
26
+ import { type DeviceRowBackend } from "./device-row-store.js";
27
+ /**
28
+ * @durable class=ledger owner=device-manager
29
+ * write="`removeDevice` records one row per deleted device (key = device id) after the
30
+ * device row is gone"
31
+ * retention="90 days (`DEVICE_TOMBSTONE_RETENTION_MS`), pruned whenever a tombstone is
32
+ * written or the list is read. Bounded by the deletions an operator makes in a quarter."
33
+ */
34
+ export declare const DEVICE_TOMBSTONES_COLLECTION = "device-manager:tombstones";
35
+ export declare const DEVICE_TOMBSTONE_COLUMNS: readonly CollectionColumn[];
36
+ export declare const DEVICE_TOMBSTONE_INDEXES: readonly CollectionIndex[];
37
+ export interface DeviceTombstoneStoreOptions {
38
+ readonly backend: DeviceRowBackend;
39
+ readonly logger: IScopedLogger;
40
+ readonly now?: () => number;
41
+ readonly retentionMs?: number;
42
+ }
43
+ export declare class DeviceTombstoneStore {
44
+ private readonly backend;
45
+ private readonly logger;
46
+ private readonly now;
47
+ private readonly retentionMs;
48
+ private declared;
49
+ constructor(opts: DeviceTombstoneStoreOptions);
50
+ /** Lazy, idempotent declaration; a rejection is not latched. */
51
+ private declare;
52
+ /** Record a deletion. Re-recording the same id keeps the EARLIER `deletedAt`. */
53
+ record(tombstone: DeviceTombstone): Promise<void>;
54
+ /** Tombstones with `deletedAt >= sinceMs`, oldest first. Expired ones are pruned first. */
55
+ listSince(sinceMs: number): Promise<readonly DeviceTombstone[]>;
56
+ /** Delete tombstones older than the retention window. Best-effort: a failure is logged. */
57
+ pruneExpired(): Promise<number>;
58
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What a device removal leaves behind is cleaned AT REMOVAL, by steps that
3
+ * cannot block one another (D864).
4
+ *
5
+ * `removeDevice` used to run its clean-up as one straight line: the first step
6
+ * that threw skipped every later one, and the keychain was not in the line at
7
+ * all — a device-scoped secret (`secret://provider-dreame/device/4483/…`)
8
+ * outlived its device for good. Each store is a {@link RemovalStep} now: run
9
+ * independently, counted, logged ONCE per device with `tags: { deviceId }`,
10
+ * and a step that failed is retried by a BOUNDED in-memory reconcile
11
+ * (a few attempts, growing delays, then it gives up loudly).
12
+ *
13
+ * There is deliberately no boot-time sweep (the operator forbids purges on
14
+ * boot): a retry that dies with the process is picked up by the keychain
15
+ * admin's orphan listing (System -> Secrets), which names a device-scoped
16
+ * secret whose device no longer points at it.
17
+ */
18
+ import { type IScopedLogger } from "@camstack/types";
19
+ /** One store that holds data of a removed device. `run` returns what it deleted. */
20
+ export interface RemovalStep {
21
+ /** The store, named for the log (`keychain-secrets`, `device-config`, …). */
22
+ readonly store: string;
23
+ /** Delete this device's data from the store; resolves to the number of rows/refs removed. */
24
+ readonly run: () => Promise<number>;
25
+ }
26
+ /** The outcome of one {@link RemovalCleanup.run}. */
27
+ export interface RemovalCleanupReport {
28
+ readonly deviceId: number;
29
+ /** Rows/refs removed per store, for the steps that succeeded. */
30
+ readonly counts: Readonly<Record<string, number>>;
31
+ /** The stores whose step threw — scheduled for a bounded retry. */
32
+ readonly failed: readonly string[];
33
+ }
34
+ /** Retry delays after a failed step; the list length bounds the attempts. */
35
+ export declare const REMOVAL_RETRY_DELAYS_MS: readonly number[];
36
+ export interface RemovalCleanupOptions {
37
+ readonly logger: IScopedLogger;
38
+ /** The log message stem; the hook fan-out (D865) runs its own instance under another one. */
39
+ readonly label?: string;
40
+ readonly retryDelaysMs?: readonly number[];
41
+ readonly setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
42
+ readonly clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
43
+ }
44
+ export interface RemovalCleanup {
45
+ /** Run every step independently; log once; schedule a bounded retry for the failures. */
46
+ run(deviceId: number, steps: readonly RemovalStep[]): Promise<RemovalCleanupReport>;
47
+ /** Cancel every pending retry (addon shutdown). */
48
+ dispose(): void;
49
+ }
50
+ export declare function createRemovalCleanup(opts: RemovalCleanupOptions): RemovalCleanup;
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DVe0Nsp8.js");
6
+ const require_dist = require("../../dist-GgG-7amw.js");
7
7
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  //#region src/builtins/hub-forwarder/hub-forwarder-destination.ts
9
9
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
@@ -1,4 +1,4 @@
1
- import { ln as logDestinationCapability, xr as BaseAddon } from "../../dist-DjD2M79P.mjs";
1
+ import { Or as BaseAddon, mn as logDestinationCapability } from "../../dist-Dc8GKC5H.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  //#region src/builtins/hub-forwarder/hub-forwarder-destination.ts
4
4
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
@@ -3,9 +3,9 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DVe0Nsp8.js");
7
- const require_device_ca = require("../../device-ca-CuWuRW9N.js");
8
- const require_constants = require("../../constants-GENKqk9k.js");
6
+ const require_dist = require("../../dist-GgG-7amw.js");
7
+ const require_device_ca = require("../../device-ca-BSvkXqyI.js");
8
+ const require_constants = require("../../constants-wkcn5Ij3.js");
9
9
  const require_platform_key_source = require("../../platform-key-source-Di7HrlGR.js");
10
10
  let node_crypto = require("node:crypto");
11
11
  let _camstack_types_node = require("@camstack/types/node");
@@ -709,6 +709,38 @@ var KeychainService = class {
709
709
  }
710
710
  return deleted;
711
711
  }
712
+ /**
713
+ * Delete EVERY value whose scope is `device/<deviceId>`, whatever its owner
714
+ * (D864). A device-scoped secret is owned by the driver that declared it, so
715
+ * the device manager — which knows only that the device is gone — cannot name
716
+ * the owner; this is the one owner-spanning delete, and it is reserved to
717
+ * the device manager (a removal is the only moment a whole device scope
718
+ * stops being anybody's). Works while locked: it removes ciphertext, it
719
+ * never reads it.
720
+ *
721
+ * @returns the refs removed (never values), for the caller's one log line.
722
+ */
723
+ async deleteDeviceScope(caller, deviceId) {
724
+ if (caller === null || require_dist.normalizeSecretOwner(caller) !== "device-manager") throw new KeychainForbiddenError(`device/${deviceId}`, caller);
725
+ const scope = require_dist.formatSecretScope({
726
+ kind: "device",
727
+ deviceId
728
+ });
729
+ const rows = (await this.deps.repo.listSecrets()).filter((row) => row.scope === scope);
730
+ if (rows.length === 0) return [];
731
+ const refs = rows.map((row) => row.ref);
732
+ await this.deps.repo.deleteSecrets(refs);
733
+ for (const row of rows) {
734
+ await this.audit("delete", actorOfAddon(caller), row.ref, "device-removed");
735
+ this.deps.events.emit({
736
+ ref: row.ref,
737
+ owner: row.owner,
738
+ version: null,
739
+ change: "delete"
740
+ });
741
+ }
742
+ return refs;
743
+ }
712
744
  async listMeta(caller) {
713
745
  return (await this.deps.repo.listSecrets()).filter((row) => this.owns(caller, row.owner)).map(toMeta);
714
746
  }
@@ -1040,7 +1072,7 @@ var KEYCHAIN_SECRETS_COLLECTION = "keychain:secrets";
1040
1072
  */
1041
1073
  var KEYCHAIN_KEYS_COLLECTION = "keychain:keys";
1042
1074
  /**
1043
- * @durable class=ring owner=keychain
1075
+ * @durable class=audit owner=keychain
1044
1076
  * write="one row per write / delete / rotation / reveal / recovery download"
1045
1077
  * retention="90 days and at most 20 000 rows, pruned at boot and daily"
1046
1078
  */
@@ -1817,6 +1849,7 @@ var KeychainAddon = class extends require_dist.BaseAddon {
1817
1849
  put: (input) => service.put(callerOf(input), input.ref, input.value),
1818
1850
  resolve: (input) => service.resolve(callerOf(input), input.refs),
1819
1851
  delete: async (input) => ({ deleted: await service.delete(callerOf(input), input.refs) }),
1852
+ deleteDeviceScope: async (input) => ({ refs: await service.deleteDeviceScope(callerOf(input), input.deviceId) }),
1820
1853
  listMeta: (input) => service.listMeta(callerOf(input)),
1821
1854
  getState: async () => service.getState()
1822
1855
  }
@@ -1,6 +1,6 @@
1
- import { P as KEYCHAIN_RESET_TRIGGER_ACTOR, Qr as normalizeSecretOwner, Ur as formatSecretScope, di as EventCategory, nn as keychainAdminCapability, rn as keychainCapability, ti as parseSecretRef, xr as BaseAddon } from "../../dist-DjD2M79P.mjs";
2
- import { d as JWT_SIGNING_KEY_REF, n as DEVICE_CA_KEY_REF, p as SYSTEM_SECRET_OWNER } from "../../device-ca-BNM5ai4t.mjs";
3
- import { a as MESH_CA_KEY_REF } from "../../constants-BFpowodq.mjs";
1
+ import { L as KEYCHAIN_RESET_TRIGGER_ACTOR, Or as BaseAddon, Xr as formatSecretScope, ai as normalizeSecretOwner, ci as parseSecretRef, cn as keychainCapability, sn as keychainAdminCapability, yi as EventCategory } from "../../dist-Dc8GKC5H.mjs";
2
+ import { d as JWT_SIGNING_KEY_REF, n as DEVICE_CA_KEY_REF, p as SYSTEM_SECRET_OWNER } from "../../device-ca-DvIZjXIl.mjs";
3
+ import { a as MESH_CA_KEY_REF } from "../../constants-DQK705Sb.mjs";
4
4
  import { n as getPlatformKeychainKey } from "../../platform-key-source-BWlq0jjn.mjs";
5
5
  import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
6
6
  import { restrictDirToOwnerSync, restrictFileToOwnerSync, writeOwnerOnlyFileSync } from "@camstack/types/node";
@@ -703,6 +703,38 @@ var KeychainService = class {
703
703
  }
704
704
  return deleted;
705
705
  }
706
+ /**
707
+ * Delete EVERY value whose scope is `device/<deviceId>`, whatever its owner
708
+ * (D864). A device-scoped secret is owned by the driver that declared it, so
709
+ * the device manager — which knows only that the device is gone — cannot name
710
+ * the owner; this is the one owner-spanning delete, and it is reserved to
711
+ * the device manager (a removal is the only moment a whole device scope
712
+ * stops being anybody's). Works while locked: it removes ciphertext, it
713
+ * never reads it.
714
+ *
715
+ * @returns the refs removed (never values), for the caller's one log line.
716
+ */
717
+ async deleteDeviceScope(caller, deviceId) {
718
+ if (caller === null || normalizeSecretOwner(caller) !== "device-manager") throw new KeychainForbiddenError(`device/${deviceId}`, caller);
719
+ const scope = formatSecretScope({
720
+ kind: "device",
721
+ deviceId
722
+ });
723
+ const rows = (await this.deps.repo.listSecrets()).filter((row) => row.scope === scope);
724
+ if (rows.length === 0) return [];
725
+ const refs = rows.map((row) => row.ref);
726
+ await this.deps.repo.deleteSecrets(refs);
727
+ for (const row of rows) {
728
+ await this.audit("delete", actorOfAddon(caller), row.ref, "device-removed");
729
+ this.deps.events.emit({
730
+ ref: row.ref,
731
+ owner: row.owner,
732
+ version: null,
733
+ change: "delete"
734
+ });
735
+ }
736
+ return refs;
737
+ }
706
738
  async listMeta(caller) {
707
739
  return (await this.deps.repo.listSecrets()).filter((row) => this.owns(caller, row.owner)).map(toMeta);
708
740
  }
@@ -1034,7 +1066,7 @@ var KEYCHAIN_SECRETS_COLLECTION = "keychain:secrets";
1034
1066
  */
1035
1067
  var KEYCHAIN_KEYS_COLLECTION = "keychain:keys";
1036
1068
  /**
1037
- * @durable class=ring owner=keychain
1069
+ * @durable class=audit owner=keychain
1038
1070
  * write="one row per write / delete / rotation / reveal / recovery download"
1039
1071
  * retention="90 days and at most 20 000 rows, pruned at boot and daily"
1040
1072
  */
@@ -1811,6 +1843,7 @@ var KeychainAddon = class extends BaseAddon {
1811
1843
  put: (input) => service.put(callerOf(input), input.ref, input.value),
1812
1844
  resolve: (input) => service.resolve(callerOf(input), input.refs),
1813
1845
  delete: async (input) => ({ deleted: await service.delete(callerOf(input), input.refs) }),
1846
+ deleteDeviceScope: async (input) => ({ refs: await service.deleteDeviceScope(callerOf(input), input.deviceId) }),
1814
1847
  listMeta: (input) => service.listMeta(callerOf(input)),
1815
1848
  getState: async () => service.getState()
1816
1849
  }
@@ -1,6 +1,8 @@
1
1
  import { type KeychainAuditEntry, type KeychainKeySource, type KeychainResetRecord, type KeychainResolveResult, type KeychainSecretMeta, type KeychainSecretUsage, type KeychainState, type KeychainStatus, type KeychainUnavailableReason, type KnownEventCategory, type SystemHeldSecretName } from "@camstack/types";
2
2
  import { type KeySourceCandidate } from "./key-sources.js";
3
3
  import type { KeychainRepository, KeychainSecretRow } from "./keychain-store.js";
4
+ /** The one caller that may purge a whole device scope across owners (D864). */
5
+ export declare const DEVICE_SCOPE_PURGER = "device-manager";
4
6
  /** `lastReadAt` is written at most this often per ref (hub I/O is the bottleneck). */
5
7
  export declare const LAST_READ_GRANULARITY_MS: number;
6
8
  export declare class KeychainLockedError extends Error {
@@ -69,6 +71,18 @@ export declare class KeychainService {
69
71
  resolve(caller: string | null, refs: readonly string[]): Promise<KeychainResolveResult>;
70
72
  private touchLastRead;
71
73
  delete(caller: string | null, refs: readonly string[]): Promise<number>;
74
+ /**
75
+ * Delete EVERY value whose scope is `device/<deviceId>`, whatever its owner
76
+ * (D864). A device-scoped secret is owned by the driver that declared it, so
77
+ * the device manager — which knows only that the device is gone — cannot name
78
+ * the owner; this is the one owner-spanning delete, and it is reserved to
79
+ * the device manager (a removal is the only moment a whole device scope
80
+ * stops being anybody's). Works while locked: it removes ciphertext, it
81
+ * never reads it.
82
+ *
83
+ * @returns the refs removed (never values), for the caller's one log line.
84
+ */
85
+ deleteDeviceScope(caller: string | null, deviceId: number): Promise<readonly string[]>;
72
86
  listMeta(caller: string | null): Promise<readonly KeychainSecretMeta[]>;
73
87
  status(): Promise<KeychainStatus>;
74
88
  listSecrets(): Promise<readonly (KeychainSecretMeta & {
@@ -25,7 +25,7 @@ export declare const KEYCHAIN_SECRETS_COLLECTION = "keychain:secrets";
25
25
  */
26
26
  export declare const KEYCHAIN_KEYS_COLLECTION = "keychain:keys";
27
27
  /**
28
- * @durable class=ring owner=keychain
28
+ * @durable class=audit owner=keychain
29
29
  * write="one row per write / delete / rotation / reveal / recovery download"
30
30
  * retention="90 days and at most 20 000 rows, pruned at boot and daily"
31
31
  */
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-DVe0Nsp8.js");
3
+ const require_dist = require("../../dist-GgG-7amw.js");
4
4
  //#region src/builtins/liveness-monitor/liveness-checks.ts
5
5
  var NO_DEVICES = "liveness:no-devices";
6
6
  var ALL_OFFLINE = "liveness:all-devices-offline";
@@ -1,4 +1,4 @@
1
- import { H as RecordingStatusSchema, Lr as createEvent, V as RECORDING_SOURCE_CAMSTACK_ADDON, br as errMsg, di as EventCategory, xr as BaseAddon } from "../../dist-DjD2M79P.mjs";
1
+ import { Dr as errMsg, G as RecordingStatusSchema, Or as BaseAddon, W as RECORDING_SOURCE_CAMSTACK_ADDON, Wr as createEvent, yi as EventCategory } from "../../dist-Dc8GKC5H.mjs";
2
2
  //#region src/builtins/liveness-monitor/liveness-checks.ts
3
3
  var NO_DEVICES = "liveness:no-devices";
4
4
  var ALL_OFFLINE = "liveness:all-devices-offline";
@@ -3,10 +3,10 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DVe0Nsp8.js");
6
+ const require_dist = require("../../dist-GgG-7amw.js");
7
7
  const require_safe_buffer$1 = require("../../safe-buffer-D3aoa-7X.js");
8
8
  const require_ms$1 = require("../../ms-LB3giGoU.js");
9
- const require_device_ca = require("../../device-ca-CuWuRW9N.js");
9
+ const require_device_ca = require("../../device-ca-BSvkXqyI.js");
10
10
  let node_crypto = require("node:crypto");
11
11
  node_crypto = require_chunk.__toESM(node_crypto);
12
12
  let crypto$1 = require("crypto");
@@ -6458,6 +6458,33 @@ var ScopedTokenManager = class {
6458
6458
  }
6459
6459
  };
6460
6460
  //#endregion
6461
+ //#region src/auth/session-revocation-bus.ts
6462
+ var BUS_KEY = Symbol.for("camstack.login-sessions.revocation-bus");
6463
+ function listeners() {
6464
+ const existing = Reflect.get(globalThis, BUS_KEY);
6465
+ if (existing instanceof Set) return existing;
6466
+ const created = /* @__PURE__ */ new Set();
6467
+ Reflect.set(globalThis, BUS_KEY, created);
6468
+ return created;
6469
+ }
6470
+ /** Subscribe; returns the unsubscribe. */
6471
+ function onSessionsRevoked(listener) {
6472
+ const set = listeners();
6473
+ set.add(listener);
6474
+ return () => {
6475
+ set.delete(listener);
6476
+ };
6477
+ }
6478
+ /** Announce an ended batch. A listener that throws never reaches the revoke. */
6479
+ function announceSessionsRevoked(event, onListenerError) {
6480
+ if (event.sessionIds.length === 0 && !event.allOfUser) return;
6481
+ for (const listener of [...listeners()]) try {
6482
+ listener(event);
6483
+ } catch (error) {
6484
+ onListenerError?.(error);
6485
+ }
6486
+ }
6487
+ //#endregion
6461
6488
  //#region src/auth/login-session-manager.ts
6462
6489
  /**
6463
6490
  * Login sessions — the server-side record behind every session token.
@@ -6807,6 +6834,7 @@ var LoginSessionManager = class {
6807
6834
  revokedReason: reason
6808
6835
  };
6809
6836
  this.replace(session, updated);
6837
+ this.announce([updated.id], session.userId, false, null, reason);
6810
6838
  await this.persist(updated);
6811
6839
  return true;
6812
6840
  }
@@ -6830,6 +6858,7 @@ var LoginSessionManager = class {
6830
6858
  this.replace(session, updated);
6831
6859
  ended.push(updated);
6832
6860
  }
6861
+ this.announce(ended.map((s) => s.id), userId, true, options.exceptSessionId ?? null, options.reason);
6833
6862
  await this.persistCutoff(userId, at);
6834
6863
  for (const session of ended) await this.persist(session);
6835
6864
  return ended.length;
@@ -6848,6 +6877,7 @@ var LoginSessionManager = class {
6848
6877
  this.replace(session, updated);
6849
6878
  ended.push(updated);
6850
6879
  }
6880
+ this.announce(ended.map((s) => s.id), null, false, null, reason);
6851
6881
  for (const session of ended) await this.persist(session);
6852
6882
  return ended.length;
6853
6883
  }
@@ -6873,6 +6903,16 @@ var LoginSessionManager = class {
6873
6903
  }
6874
6904
  return dead.length;
6875
6905
  }
6906
+ /** Tell the hub which sessions just ended — right after the mirror, before the store write. */
6907
+ announce(sessionIds, userId, allOfUser, exceptSessionId, reason) {
6908
+ announceSessionsRevoked({
6909
+ sessionIds,
6910
+ userId,
6911
+ allOfUser,
6912
+ exceptSessionId,
6913
+ reason
6914
+ }, (error) => this.logger?.error("A session-revocation listener threw — the revoke stands", { meta: { error: error instanceof Error ? error.message : String(error) } }));
6915
+ }
6876
6916
  newRefreshToken() {
6877
6917
  return `${REFRESH_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
6878
6918
  }
@@ -7036,9 +7076,9 @@ var AdminTokenManager = class {
7036
7076
  /** Mint a token for `createdBy`. Refuses an expiry in the past or past 90 days. */
7037
7077
  async create(input) {
7038
7078
  const at = this.now();
7039
- if (!(input.expiresAt > at) || input.expiresAt > at + 7776e6) throw new Error("An admin token must expire within 90 days");
7079
+ if (!(input.expiresAt > at) || input.expiresAt > at + 7776e6) throw require_dist.userError("system.admin-token-expiry-too-long", void 0, "An admin token must expire within 90 days");
7040
7080
  const name = input.name.trim().slice(0, 80);
7041
- if (name.length === 0) throw new Error("An admin token needs a name");
7081
+ if (name.length === 0) throw require_dist.userError("system.admin-token-name-required", void 0, "An admin token needs a name");
7042
7082
  const token = `${ADMIN_TOKEN_PREFIX}${node_crypto.randomBytes(32).toString("base64url")}`;
7043
7083
  const stored = {
7044
7084
  id: node_crypto.randomUUID(),
@@ -7427,7 +7467,7 @@ var DeviceCredentialManager = class {
7427
7467
  /** A one-shot code for `userId` (admin path). Returned once; only its hash is kept. */
7428
7468
  startEnrollment(input) {
7429
7469
  this.prune();
7430
- if (this.pendingEnrollments.size >= MAX_PENDING) throw new Error("Too many pending device enrollments — try again shortly");
7470
+ if (this.pendingEnrollments.size >= MAX_PENDING) throw require_dist.userError("system.device-enrollment-busy", void 0, "Too many pending device enrollments — try again shortly");
7431
7471
  const code = node_crypto.randomBytes(24).toString("base64url");
7432
7472
  const expiresAt = this.now() + require_dist.DEVICE_ENROLLMENT_TTL_MS;
7433
7473
  this.pendingEnrollments.set(hash(code), {
@@ -7998,7 +8038,7 @@ var UserManager = class {
7998
8038
  return this.storageAccess.getStore();
7999
8039
  }
8000
8040
  async create(input) {
8001
- if (await this.findByUsername(input.username)) throw new Error(`User with username "${input.username}" already exists`);
8041
+ if (await this.findByUsername(input.username)) throw require_dist.userError("system.user-exists", { username: String(input.username) }, `User with username "${input.username}" already exists`);
8002
8042
  const passwordHash = await this.auth.hashPassword(input.password);
8003
8043
  const now = Date.now();
8004
8044
  const record = {
@@ -8050,7 +8090,7 @@ var UserManager = class {
8050
8090
  }
8051
8091
  async update(id, data) {
8052
8092
  const existing = await this.findById(id);
8053
- if (!existing) throw new Error(`User with id "${id}" not found`);
8093
+ if (!existing) throw require_dist.userError("system.user-not-found", { id: String(id) }, `User with id "${id}" not found`);
8054
8094
  await this.store.update.mutate({
8055
8095
  collection: USERS_COLLECTION$1,
8056
8096
  id,
@@ -8069,7 +8109,7 @@ var UserManager = class {
8069
8109
  }
8070
8110
  async resetPassword(id, newPassword) {
8071
8111
  const existing = await this.findById(id);
8072
- if (!existing) throw new Error(`User with id "${id}" not found`);
8112
+ if (!existing) throw require_dist.userError("system.user-not-found", { id: String(id) }, `User with id "${id}" not found`);
8073
8113
  const passwordHash = await this.auth.hashPassword(newPassword);
8074
8114
  await this.store.update.mutate({
8075
8115
  collection: USERS_COLLECTION$1,
@@ -11736,7 +11776,7 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11736
11776
  },
11737
11777
  confirmTotp: async (input) => {
11738
11778
  if (!this.totpManager) throw new Error("TOTP management not available");
11739
- if (!await this.totpManager.confirm(input.userId, input.code)) throw new Error("TOTP confirmation failed — code did not match");
11779
+ if (!await this.totpManager.confirm(input.userId, input.code)) throw require_dist.userError("auth.totp-confirm-failed", void 0, "TOTP confirmation failed — code did not match");
11740
11780
  return { success: true };
11741
11781
  },
11742
11782
  disableTotp: async (input) => {
@@ -11794,7 +11834,7 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11794
11834
  /** The admin cap (`device-credentials`): list, enroll-by-QR, revoke, rename, settings. */
11795
11835
  buildDeviceCredentialsProvider() {
11796
11836
  const manager = () => {
11797
- if (this.deviceCredentials === null) throw new Error("Device credentials not available");
11837
+ if (this.deviceCredentials === null) throw require_dist.userError("device.credentials-unavailable", void 0, "Device credentials not available");
11798
11838
  return this.deviceCredentials;
11799
11839
  };
11800
11840
  const hubOrigins = async () => {
@@ -11846,7 +11886,7 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11846
11886
  },
11847
11887
  revokeDeviceCredential: async (input) => {
11848
11888
  const record = manager().get(input.id);
11849
- if (record === void 0) throw new Error(`Device credential not found: ${input.id}`);
11889
+ if (record === void 0) throw require_dist.userError("device.credential-not-found", { id: input.id }, `Device credential not found: ${input.id}`);
11850
11890
  const revoked = await manager().revoke(input.id, require_dist.formatDeviceRevoker({
11851
11891
  kind: "admin",
11852
11892
  userId: input.caller.userId
@@ -11917,7 +11957,7 @@ var LocalAuthAddon = class extends require_dist.BaseAddon {
11917
11957
  return this.loginSessions;
11918
11958
  };
11919
11959
  const devices = () => {
11920
- if (this.deviceCredentials === null) throw new Error("Device credentials not available");
11960
+ if (this.deviceCredentials === null) throw require_dist.userError("device.credentials-unavailable", void 0, "Device credentials not available");
11921
11961
  return this.deviceCredentials;
11922
11962
  };
11923
11963
  return {
@@ -12093,4 +12133,5 @@ exports.UserManager = UserManager;
12093
12133
  exports.attachUserManagementInternal = attachUserManagementInternal;
12094
12134
  exports.getUserManagementInternal = getUserManagementInternal;
12095
12135
  exports.isSettingsReadUnavailable = isSettingsReadUnavailable;
12136
+ exports.onSessionsRevoked = onSessionsRevoked;
12096
12137
  exports.parseTokenExpiry = parseTokenExpiry;