dsh-push-host 0.0.0-stage → 0.3.2

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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Credentials for the DSH push host plugin.
3
+ *
4
+ * One file carries a whole user's push channel, so an operator never has to keep three secrets in
5
+ * environment variables (and never has to edit a YAML with secrets in it):
6
+ *
7
+ * ~/.config/dsh-push/credentials.json
8
+ *
9
+ * The natural way to create it is to save the **pairing payload** that
10
+ * `deploy/relay-user-add.sh <user>` printed, or to run the plugin's own `dsh-push-pair` command,
11
+ * which writes the same shape. Both are accepted verbatim:
12
+ *
13
+ * { "v": 1, "relay": "https://ntfy.cpyh.xyz", "host": "alice", "topic": "dsh-alice",
14
+ * "writeToken": "tk_…", // ← this host publishes with it (plugin side)
15
+ * "readToken": "tk_…", // ← the phone subscribes with it (phone side)
16
+ * "hostToken": "hk_…" } // ← relay queue token (plugin side)
17
+ *
18
+ * Field names are matched case-insensitively, in this order:
19
+ *
20
+ * hostToken → hostToken
21
+ * writeToken → ntfyToken (also accepted: ntfyToken, pushToken, publishToken, tkWrite, tkPub)
22
+ * readToken → phoneToken (also accepted: phoneToken, tkRead, tkPhone) — informational only;
23
+ * the plugin never reads the topic, only the phone does
24
+ * regToken → regToken (also accepted: registrationToken) — the relay's registration
25
+ * secret (`/etc/answer-relay/regtoken`), used as `X-Reg-Token`
26
+ * by `POST /answer/register`
27
+ * relay → relayBase (also: relayBase, base)
28
+ * host / hostId, topic
29
+ * webBase / webName / answerBase / pollIntervalMs / credentialsFile (optional extras)
30
+ *
31
+ * Nested form (one file, several hosts) is also accepted; the root fields act as defaults:
32
+ *
33
+ * { "relay": "https://ntfy.cpyh.xyz", "host": "alice",
34
+ * "hosts": { "alice": { "topic": "dsh-alice", "writeToken": "tk_…",
35
+ * "readToken": "tk_…", "hostToken": "hk_…" } } }
36
+ *
37
+ * Layering (later wins), resolved by `resolvePluginConfig` in `lib/index.js`:
38
+ *
39
+ * built-in defaults → credentials file → profile/overlay config → environment variables
40
+ *
41
+ * A missing file is a clean no-op (empty credentials, no log line at all): a profile that has no
42
+ * credentials degrades to the transparent listener it already is. An unreadable or malformed file
43
+ * is reported — the operator must be able to tell "not set up yet" from "set up but broken".
44
+ */
45
+ import { readFileSync } from 'node:fs'
46
+ import { homedir } from 'node:os'
47
+ import { join, resolve } from 'node:path'
48
+
49
+ /**
50
+ * Default credentials location, anchored to the user's home directory.
51
+ *
52
+ * The `~` is load-bearing (2026-10-13): this used to be the bare relative path
53
+ * `.config/dsh-push/credentials.json`, which `expandHome` fed to `path.resolve()` and therefore
54
+ * resolved against the **process cwd**, not the home directory. The production Web host happens to
55
+ * run with cwd = `C:\Users\cnop`, so it found the file and nobody noticed; a DSH started from any
56
+ * other directory (a workspace, the repo, a shortcut's "start in", …) silently lost its credentials
57
+ * and degraded to the transparent no-op — i.e. the phone just goes quiet, with no error anywhere,
58
+ * which is the worst possible failure signature for a push channel. A relative path a user writes
59
+ * into `credentialsFile` still resolves against the cwd exactly as before; only the *default* is
60
+ * now home-anchored.
61
+ */
62
+ export const CREDENTIALS_FILE = '~/.config/dsh-push/credentials.json'
63
+
64
+ /** Env var that overrides the credentials file location (used by ops and by the self-test). */
65
+ export const CREDENTIALS_ENV = 'DSH_PUSH_CREDENTIALS'
66
+
67
+ /** The three tokens, in the vocabulary this plugin uses, mapped to the accepted file keys. */
68
+ export const CREDENTIAL_KEYS = {
69
+ hostToken: ['hosttoken'],
70
+ // 2026-10-12 (production cutover): the relay's registration secret was missing from this table
71
+ // even though `CREDENTIAL_CONFIG_KEYS` in lib/index.js had been taught about it, so
72
+ // `parseCredentials` never produced a `regToken` and `X-Reg-Token` went out empty ->
73
+ // `POST /answer/register` answered `401 bad reg token` on a perfectly good credentials file.
74
+ // The two lists have to stay in sync: index.js decides *which* keys may come from the file,
75
+ // this table decides which file keys are *read* in the first place.
76
+ regToken: ['regtoken', 'registrationtoken'],
77
+ ntfyToken: ['ntfytoken', 'writetoken', 'pushtoken', 'publishtoken', 'tkwrite', 'tkpub'],
78
+ phoneToken: ['phonetoken', 'readtoken', 'tkread', 'tkphone'],
79
+ host: ['host', 'hostid'],
80
+ topic: ['topic'],
81
+ relayBase: ['relaybase', 'relay', 'base'],
82
+ answerBase: ['answerbase'],
83
+ webBase: ['webbase'],
84
+ webName: ['webname'],
85
+ pollIntervalMs: ['pollintervalms'],
86
+ credentialsFile: ['credentialsfile'],
87
+ // 2026-10-17 (v0.3.0 发号面板):同一个坑又踩一次——provisionToken/provisionBase 加进了
88
+ // index.js 的 CREDENTIAL_CONFIG_KEYS,却没进这张表,于是 parseCredentials 根本不读它,
89
+ // 面板回「缺少发号密钥」而文件里明明有(真机实测)。webLog 是同一类潜在漏,一并补上。
90
+ // 现在 selftest 有一条断言比对两张清单,漏一个就 FAIL。
91
+ provisionToken: ['provisiontoken'],
92
+ provisionBase: ['provisionbase'],
93
+ webLog: ['weblog'],
94
+ }
95
+
96
+ /** Keys that hold secrets: they are never echoed back in logs or diagnostic output. */
97
+ export const SECRET_KEYS = ['hostToken', 'ntfyToken', 'phoneToken', 'regToken', 'provisionToken']
98
+
99
+ /** `~` / `~/x` / `~\x` → the real home path; everything else resolves against `base`. */
100
+ export function expandHome(input, home = homedir()) {
101
+ const text = String(input ?? '').trim()
102
+ if (text.length === 0) return ''
103
+ if (text === '~') return home
104
+ if (text.startsWith('~/') || text.startsWith('~\\')) return join(home, text.slice(2))
105
+ return resolve(text)
106
+ }
107
+
108
+ /** `env.DSH_PUSH_CREDENTIALS` else `~/.config/dsh-push/credentials.json`. */
109
+ export function credentialsPath(config = {}, env = process.env, home = homedir()) {
110
+ const configured = String(config.credentialsFile ?? '').trim()
111
+ const fromEnv = String(env[CREDENTIALS_ENV] ?? '').trim()
112
+ return expandHome(configured.length > 0 ? configured : (fromEnv.length > 0 ? fromEnv : CREDENTIALS_FILE), home)
113
+ }
114
+
115
+ function asString(value) {
116
+ return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''
117
+ }
118
+
119
+ function pick(object, keys) {
120
+ for (const key of keys) {
121
+ const value = asString(object[key])
122
+ if (value.length > 0) return value
123
+ }
124
+ return ''
125
+ }
126
+
127
+ function pickNumber(object, keys) {
128
+ const text = pick(object, keys)
129
+ if (text.length === 0) return undefined
130
+ const value = Number(text)
131
+ return Number.isFinite(value) ? value : undefined
132
+ }
133
+
134
+ /** Lower-case every key of a plain object (tolerates `HostToken`, `host_token`, …). */
135
+ function normalizeKeys(object) {
136
+ const out = {}
137
+ if (object === null || typeof object !== 'object' || Array.isArray(object)) return out
138
+ for (const [key, value] of Object.entries(object)) out[String(key).toLowerCase().replace(/[_-]/g, '')] = value
139
+ return out
140
+ }
141
+
142
+ /** Project one normalized object (root or a single host entry) into plugin vocabulary. */
143
+ function projectEntry(normalized) {
144
+ const out = {}
145
+ for (const [field, keys] of Object.entries(CREDENTIAL_KEYS)) {
146
+ if (field === 'pollIntervalMs') {
147
+ const value = pickNumber(normalized, keys)
148
+ if (value !== undefined) out[field] = value
149
+ continue
150
+ }
151
+ const value = pick(normalized, keys)
152
+ if (value.length > 0) out[field] = value
153
+ }
154
+ return out
155
+ }
156
+
157
+ /**
158
+ * Parse credentials text (pure; exported for the self-test).
159
+ * @returns `{ value, error }`: `value` is always an object (possibly empty); `error` is a message
160
+ * when the text was present but unusable.
161
+ */
162
+ export function parseCredentials(text, { source = CREDENTIALS_FILE } = {}) {
163
+ if (typeof text !== 'string' || text.trim().length === 0) return { value: {}, error: undefined }
164
+ let parsed
165
+ try {
166
+ parsed = JSON.parse(text)
167
+ } catch (error) {
168
+ return { value: {}, error: `${source} is not valid JSON (${error?.message ?? error})` }
169
+ }
170
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
171
+ return { value: {}, error: `${source} must contain a JSON object` }
172
+ }
173
+ const root = normalizeKeys(parsed)
174
+ const base = projectEntry(root)
175
+ const hosts = root.hosts ?? root.hostentries ?? root.credentials
176
+ if (hosts === null || typeof hosts !== 'object' || Array.isArray(hosts)) return { value: base, error: undefined }
177
+
178
+ const wanted = asString(base.host)
179
+ const names = Object.keys(hosts)
180
+ const chosen = wanted.length > 0 && Object.hasOwn(hosts, wanted) ? wanted : names[0]
181
+ if (chosen === undefined) return { value: base, error: undefined }
182
+ const entry = projectEntry(normalizeKeys(hosts[chosen]))
183
+ // Root fields are defaults for every host entry; the entry itself wins.
184
+ return { value: { ...base, host: entry.host || wanted || names[0] || '', ...entry }, error: undefined }
185
+ }
186
+
187
+ /**
188
+ * Load credentials for real: missing file → clean no-op, broken file → reported.
189
+ * @returns `{ value, path, present, error }`.
190
+ */
191
+ export function loadCredentials(path, { readFile = readFileSync } = {}) {
192
+ let text
193
+ try {
194
+ text = readFile(path, 'utf8')
195
+ } catch (error) {
196
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
197
+ return { value: {}, path, present: false, error: undefined }
198
+ }
199
+ return { value: {}, path, present: true, error: `cannot read ${path} (${error?.code ?? error?.message ?? error})` }
200
+ }
201
+ const { value, error } = parseCredentials(text, { source: path })
202
+ return { value, path, present: true, error }
203
+ }
204
+
205
+ /** Log one line per problem through the supplied sink (the plugin passes its own logger). */
206
+ export function reportCredentials(result, log = () => {}) {
207
+ if (result.error !== undefined) log('credentials ignored: ' + result.error)
208
+ }
209
+
210
+ /** `[name, value]` pairs for diagnostics: secrets are masked, never printed. */
211
+ export function describeSecrets(values) {
212
+ return SECRET_KEYS.filter((key) => typeof values?.[key] === 'string' && values[key].length > 0).map(
213
+ (key) => key + '=' + values[key].slice(0, 3) + '…(' + String(values[key].length) + ' chars)',
214
+ )
215
+ }