@summerscar/dsh-mnemon-sync 0.0.0-stage → 0.1.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/store.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Mnemon Sync — settings store.
3
+ *
4
+ * Settings live in `<dataRoot>/state/mnemon-sync.json` at mode 0600, never in
5
+ * the DSH profile patch. Two reasons, both learned from how dsh-mnemon keeps
6
+ * its own Git credentials:
7
+ *
8
+ * - The profile patch is plain text that the DSH UI renders and that any
9
+ * profile backup copies wholesale; a WebDAV password or S3 secret key has no
10
+ * business there.
11
+ * - `state/` is deliberately not part of a Mnemon Pack, so a backup never
12
+ * carries the secret that unlocks the backup target.
13
+ *
14
+ * The file is written whole through a temporary sibling, so a crash mid-write
15
+ * leaves the previous settings intact instead of a truncated JSON file.
16
+ */
17
+
18
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+
21
+ const FILE = 'mnemon-sync.json'
22
+ const DIRECTORY = 'state'
23
+
24
+ /**
25
+ * The intervals the settings page offers, in minutes.
26
+ *
27
+ * Copied from dsh-mnemon's own Git auto-backup so the two backup surfaces read
28
+ * the same: off, hourly, 3/6/12 hours, daily, every 3 days, weekly. A value
29
+ * outside this list is still accepted when a profile sets one by hand — the
30
+ * list only constrains what the UI offers.
31
+ */
32
+ export const AUTO_SYNC_CHOICES = [0, 60, 180, 360, 720, 1440, 4320, 10080]
33
+
34
+ export const DEFAULT_SETTINGS = Object.freeze({
35
+ enabled: false,
36
+ target: { kind: 'webdav', url: '', username: '', password: '', remotePath: 'mnemon-sync' },
37
+ // Which dsh-mnemon components a backup carries. All three by default: a
38
+ // backup that silently omits memory spaces would be the worst default.
39
+ components: ['memory-spaces', 'documents', 'runtime'],
40
+ // One number, as dsh-mnemon's Git channel stores it: 0 means "only when you
41
+ // press the button", anything else is a whole number of minutes. Keeping the
42
+ // representation identical avoids a second vocabulary for the same idea.
43
+ autoSyncMinutes: 0,
44
+ retention: { keep: 14 },
45
+ })
46
+
47
+ export function settingsPath(dataRoot) {
48
+ return join(dataRoot, DIRECTORY, FILE)
49
+ }
50
+
51
+ /** Read settings, falling back to defaults for anything missing or malformed. */
52
+ export function readSettings(dataRoot) {
53
+ const path = settingsPath(dataRoot)
54
+ if (!existsSync(path)) return clone(DEFAULT_SETTINGS)
55
+ try {
56
+ const stored = JSON.parse(readFileSync(path, 'utf8'))
57
+ return normalizeSettings(stored)
58
+ } catch {
59
+ // A hand-edited file with a stray comma must not stop a backup from
60
+ // running with sane defaults; the settings page shows the defaults back.
61
+ return clone(DEFAULT_SETTINGS)
62
+ }
63
+ }
64
+
65
+ export function writeSettings(dataRoot, value) {
66
+ const normalized = normalizeSettings(value)
67
+ const path = settingsPath(dataRoot)
68
+ mkdirSync(join(dataRoot, DIRECTORY), { recursive: true, mode: 0o700 })
69
+ const temporary = `${path}.${String(process.pid)}.tmp`
70
+ writeFileSync(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
71
+ try {
72
+ renameSync(temporary, path)
73
+ } catch (error) {
74
+ // Platforms that cannot replace an existing file keep the old content.
75
+ writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
76
+ throw error
77
+ }
78
+ try {
79
+ chmodSync(path, 0o600)
80
+ } catch {
81
+ // Best effort on filesystems with no POSIX modes.
82
+ }
83
+ return normalized
84
+ }
85
+ /** Coerce arbitrary input into the exact shape the uploaders and timer expect. */
86
+ export function normalizeSettings(raw) {
87
+ const source = isRecord(raw) ? raw : {}
88
+ return {
89
+ enabled: source.enabled === true,
90
+ target: normalizeTargetSettings(source.target),
91
+ components: normalizeComponents(source.components),
92
+ autoSyncMinutes: normalizeAutoSyncMinutes(source.autoSyncMinutes),
93
+ retention: {
94
+ keep: clampInt(source.retention?.keep, 1, 365, 14),
95
+ },
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Which components a backup carries.
101
+ *
102
+ * An absent value means "everything", which is what a caller that sends only
103
+ * what it changed expects. An explicit empty list is a mistake rather than a
104
+ * choice — a backup of nothing is not a backup — so it falls back too.
105
+ */
106
+ function normalizeComponents(value) {
107
+ if (value === undefined || value === null) return [...DEFAULT_SETTINGS.components]
108
+ const requested = Array.isArray(value) ? value.map(String) : []
109
+ const known = DEFAULT_SETTINGS.components.filter((component) => requested.includes(component))
110
+ return known.length === 0 ? [...DEFAULT_SETTINGS.components] : known
111
+ }
112
+
113
+ /**
114
+ * One interval in minutes. Zero disables the timer; anything else is clamped to
115
+ * the same ceiling dsh-mnemon's form uses, so a typo cannot schedule a run a
116
+ * year out.
117
+ */
118
+ export function normalizeAutoSyncMinutes(value) {
119
+ if (value === null || value === undefined || value === '') return 0
120
+ const minutes = typeof value === 'number' ? value : Number(String(value).trim())
121
+ if (!Number.isInteger(minutes) || minutes < 0 || minutes > 10080) return 0
122
+ return minutes
123
+ }
124
+
125
+ function normalizeTargetSettings(raw) {
126
+ const source = isRecord(raw) ? raw : {}
127
+ const kind = source.kind === 's3' ? 's3' : 'webdav'
128
+ if (kind === 'webdav') {
129
+ return {
130
+ kind,
131
+ url: String(source.url ?? ''),
132
+ username: String(source.username ?? ''),
133
+ password: String(source.password ?? ''),
134
+ remotePath: normalizeRemotePath(source.remotePath),
135
+ }
136
+ }
137
+ return {
138
+ kind,
139
+ endpoint: String(source.endpoint ?? ''),
140
+ bucket: String(source.bucket ?? ''),
141
+ region: String(source.region ?? '') || 'auto',
142
+ accessKeyId: String(source.accessKeyId ?? ''),
143
+ secretAccessKey: String(source.secretAccessKey ?? ''),
144
+ sessionToken: String(source.sessionToken ?? ''),
145
+ remotePath: normalizeRemotePath(source.remotePath),
146
+ forcePathStyle: source.forcePathStyle === true,
147
+ }
148
+ }
149
+
150
+ function normalizeRemotePath(value) {
151
+ const text = String(value ?? '').trim().replace(/^\/+/u, '').replace(/\/+$/u, '')
152
+ return text === '' ? 'mnemon-sync' : text
153
+ }
154
+
155
+ function clampInt(value, minimum, maximum, fallback) {
156
+ const number = Number(value)
157
+ if (!Number.isFinite(number)) return fallback
158
+ const integer = Math.floor(number)
159
+ if (integer < minimum) return minimum
160
+ if (integer > maximum) return maximum
161
+ return integer
162
+ }
163
+
164
+ function isRecord(value) {
165
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
166
+ }
167
+
168
+ function clone(value) {
169
+ return JSON.parse(JSON.stringify(value))
170
+ }
171
+
172
+ /**
173
+ * The public view of settings: identical to what is stored, except that a
174
+ * secret is reported by presence rather than by value. The UI needs to know a
175
+ * password is set without being handed it, so a screenshot or a support paste
176
+ * never leaks the target credentials.
177
+ */
178
+ export function maskSettings(settings) {
179
+ const masked = clone(settings)
180
+ if (masked.target.kind === 'webdav') {
181
+ masked.target.password = masked.target.password === '' ? '' : '••••••••'
182
+ } else {
183
+ masked.target.secretAccessKey = masked.target.secretAccessKey === '' ? '' : '••••••••'
184
+ masked.target.sessionToken = masked.target.sessionToken === '' ? '' : '••••••••'
185
+ }
186
+ return masked
187
+ }