dsh-computers 0.0.0-stage → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/state.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Host-side persisted registry (PROTOCOL §2).
3
+ *
4
+ * One JSON document under `${stateDir || $DSH_HOME/computers}/state.json`,
5
+ * written atomically (same-directory tmp file + rename) with mode 0600. The
6
+ * loaded document is the single source of truth in memory; every mutation goes
7
+ * through `mutate()` so prune + persist cannot be forgotten.
8
+ *
9
+ * Node builtins only.
10
+ */
11
+
12
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
13
+ import { randomBytes } from 'node:crypto'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+
17
+ /** On-disk schema version (the browser API reports its own version 2). */
18
+ export const STATE_VERSION = 1
19
+
20
+ /** A consumed key and an expired key are both dropped one hour after the fact. */
21
+ export const PENDING_KEY_PRUNE_MS = 60 * 60 * 1000
22
+
23
+ /**
24
+ * Resolve the state directory: explicit config first, then `$DSH_HOME`, then
25
+ * `~/.dsh` (the harness default).
26
+ * @param {string | undefined} configured - `config.stateDir`.
27
+ * @returns {string} absolute directory path.
28
+ */
29
+ export function resolveStateDir(configured) {
30
+ if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
31
+ const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
32
+ ? process.env.DSH_HOME.trim()
33
+ : join(homedir(), '.dsh')
34
+ return join(home, 'computers')
35
+ }
36
+
37
+ /** A fresh, empty registry document. */
38
+ export function emptyState() {
39
+ return { version: STATE_VERSION, computers: [], pendingKeys: [] }
40
+ }
41
+
42
+ /**
43
+ * Drop pending keys the protocol lets us forget: one whose `usedAt` is older
44
+ * than an hour, or whose `expiresAt` passed more than an hour ago.
45
+ * @param {object} doc - registry document, mutated in place.
46
+ * @param {number} nowMs - current epoch milliseconds.
47
+ * @returns {number} how many entries were dropped.
48
+ */
49
+ export function prunePendingKeys(doc, nowMs) {
50
+ const before = doc.pendingKeys.length
51
+ doc.pendingKeys = doc.pendingKeys.filter((entry) => {
52
+ if (entry === null || typeof entry !== 'object') return false
53
+ const usedAt = typeof entry.usedAt === 'string' ? Date.parse(entry.usedAt) : Number.NaN
54
+ if (typeof entry.usedAt === 'string' && Number.isFinite(usedAt)) return nowMs - usedAt <= PENDING_KEY_PRUNE_MS
55
+ const expiresAt = typeof entry.expiresAt === 'string' ? Date.parse(entry.expiresAt) : Number.NaN
56
+ if (!Number.isFinite(expiresAt)) return false
57
+ return nowMs - expiresAt <= PENDING_KEY_PRUNE_MS
58
+ })
59
+ return before - doc.pendingKeys.length
60
+ }
61
+
62
+ /**
63
+ * Coerce an arbitrary parsed value into a registry document we can trust
64
+ * enough to serve. Unknown shapes are dropped rather than crashing the host.
65
+ * @param {unknown} raw - parsed JSON (or undefined when the file was missing).
66
+ * @returns {object} a valid registry document.
67
+ */
68
+ export function normalizeState(raw, now = undefined) {
69
+ const doc = emptyState()
70
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return doc
71
+ if (Array.isArray(raw.computers)) {
72
+ doc.computers = raw.computers.filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
73
+ for (const computer of doc.computers) {
74
+ // §11.6.4 migration: records written before the activation lifecycle have
75
+ // no `activatedAt` property at all. They were activated by definition, so
76
+ // backfill it from createdAt instead of hiding a working machine. A v1.1
77
+ // enrollment sets `activatedAt: null` explicitly and stays pending.
78
+ if (computer.activatedAt === undefined) {
79
+ computer.activatedAt = typeof computer.createdAt === 'string' ? computer.createdAt : new Date(now ?? Date.now()).toISOString()
80
+ }
81
+ }
82
+ }
83
+ if (Array.isArray(raw.pendingKeys)) {
84
+ doc.pendingKeys = raw.pendingKeys.filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
85
+ }
86
+ return doc
87
+ }
88
+
89
+ /**
90
+ * Create the registry store for one plugin mount.
91
+ *
92
+ * `now` is injectable so expiry/prune rules are testable without a clock.
93
+ * @param {object} options - store options.
94
+ * @param {string} options.stateDir - resolved directory (see {@link resolveStateDir}).
95
+ * @param {() => number} [options.now] - epoch-millisecond clock.
96
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
97
+ * @returns {object} the store.
98
+ */
99
+ export function createStore({ stateDir, now = Date.now, onWarn = () => {} }) {
100
+ const file = join(stateDir, 'state.json')
101
+ let doc = emptyState()
102
+
103
+ /** Read and parse the document; a corrupt file is set aside, never fatal. */
104
+ const load = () => {
105
+ let raw
106
+ try {
107
+ raw = readFileSync(file, 'utf8')
108
+ } catch (error) {
109
+ if (error && error.code !== 'ENOENT') {
110
+ onWarn(`dsh-computers: cannot read ${file}: ${error.message}`)
111
+ }
112
+ doc = emptyState()
113
+ return
114
+ }
115
+ try {
116
+ doc = normalizeState(JSON.parse(raw), now())
117
+ } catch {
118
+ // A half-written legacy file must not brick the plugin: keep it for
119
+ // inspection and start from an empty registry.
120
+ const aside = `${file}.corrupt-${now()}`
121
+ try {
122
+ renameSync(file, aside)
123
+ onWarn(`dsh-computers: ${file} was not valid JSON; moved to ${aside}`)
124
+ } catch {
125
+ onWarn(`dsh-computers: ${file} was not valid JSON`)
126
+ }
127
+ doc = emptyState()
128
+ }
129
+ }
130
+
131
+ /** Write the document atomically: tmp file in the same directory, then rename. */
132
+ const persist = () => {
133
+ try {
134
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 })
135
+ const tmp = join(stateDir, `.state.json.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
136
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
137
+ try {
138
+ chmodSync(tmp, 0o600)
139
+ } catch {
140
+ // Platforms without POSIX modes (Windows) simply skip this.
141
+ }
142
+ renameSync(tmp, file)
143
+ } catch (error) {
144
+ onWarn(`dsh-computers: cannot write ${file}: ${error && error.message ? error.message : error}`)
145
+ }
146
+ }
147
+
148
+ load()
149
+ prunePendingKeys(doc, now())
150
+ persist()
151
+
152
+ return {
153
+ /** Absolute state directory. */
154
+ dir: stateDir,
155
+ /** Absolute state file path. */
156
+ file,
157
+ /** Read-only view of the live document (callers must not mutate it). */
158
+ read: () => doc,
159
+ /** Deep copy for responses that must not alias live state. */
160
+ snapshot: () => JSON.parse(JSON.stringify(doc)),
161
+ /**
162
+ * Run one atomic read-modify-write: prune, apply `change`, persist.
163
+ * @param {(document: object) => unknown} change - mutation callback.
164
+ * @returns {unknown} whatever `change` returned.
165
+ */
166
+ mutate(change) {
167
+ const result = change(doc)
168
+ prunePendingKeys(doc, now())
169
+ persist()
170
+ return result
171
+ },
172
+ /**
173
+ * Refresh a computer's `lastSeenAt` (stream open, heartbeat, result POST).
174
+ * @param {string} computerId - computer to touch.
175
+ * @param {string} [at] - ISO timestamp; defaults to now.
176
+ * @returns {boolean} whether the computer exists.
177
+ */
178
+ touch(computerId, at = new Date(now()).toISOString()) {
179
+ const computer = doc.computers.find((entry) => entry.id === computerId)
180
+ if (computer === undefined) return false
181
+ computer.lastSeenAt = at
182
+ persist()
183
+ return true
184
+ },
185
+ /** Re-read the file from disk (used by tooling and the smoke harness). */
186
+ reload: load,
187
+ /** Expose the clock in use. */
188
+ now,
189
+ }
190
+ }