funoteka 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.
Files changed (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. package/package.json +55 -0
@@ -0,0 +1,1162 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createServer as createHttpServer, } from 'node:http';
3
+ import { createServer as createHttpsServer } from 'node:https';
4
+ import { SCHEMA_VERSION } from "../db/index.js";
5
+ import { hidden, mark, marks, resolvePath, unmark } from "../junk/marks.js";
6
+ import { inventory as inventoryDump } from "../inventory/inventory.js";
7
+ import { applyPlaylists } from "../playlist/import.js";
8
+ import { exportState, isExport, restoreState } from "./admin-export.js";
9
+ import { issues, logs, stats } from "./admin-library.js";
10
+ import { guard } from "./admin-guard.js";
11
+ import { auditLog } from "./audit.js";
12
+ import { sameSecret } from "./auth.js";
13
+ import { configReport, loadConfig } from "./config.js";
14
+ import { health } from "./health.js";
15
+ import { readConfigFile, writeConfigFile } from "./config-file.js";
16
+ import { SERVER_VERSION } from "./envelope.js";
17
+ import { recall, remember } from "./idempotency.js";
18
+ import { addKey, listKeys, newSecret, revokeKey } from "./keys.js";
19
+ import { count, scanStatus } from "./meta.js";
20
+ import { addRoot, listRoots, removeRoot } from "./roots.js";
21
+ import { handleMessage } from "../mcp/server.js";
22
+ import { SECRET_SETTINGS, SETTINGS } from "./settings.js";
23
+ /**
24
+ * The admin listener, or nothing when no token was configured.
25
+ *
26
+ * Nothing is the *disabled* state and it is deliberately `null` rather than a
27
+ * server that refuses everything: a listener on the port that answers 401 to a
28
+ * stranger is still a socket this server opened, and "off" should mean the
29
+ * connection is refused.
30
+ */
31
+ export function createAdminServer(deps) {
32
+ const { config } = deps;
33
+ if (config.token === '')
34
+ return null;
35
+ const gate = guard(config.allow);
36
+ const handle = (request, response) => {
37
+ // Every path out of `route` is accounted for: a request body can fail to
38
+ // arrive when a client hangs up, and an unhandled rejection in Node does not
39
+ // end a request — it ends the process. A control surface that a dropped
40
+ // connection can kill is worse than one that refuses.
41
+ route(deps, gate, request, response).catch((err) => {
42
+ process.stderr.write(`funoteka admin: ${String(err)}\n`);
43
+ send(response, { status: 500, body: { error: 'internal error' } });
44
+ });
45
+ };
46
+ return config.tls === null
47
+ ? createHttpServer(handle)
48
+ : createHttpsServer(tlsFor(config.tls), handle);
49
+ }
50
+ /**
51
+ * The certificate and key, read once at startup.
52
+ *
53
+ * Read here rather than checked for existence: a server that started with an
54
+ * unreadable certificate would fail on its first connection instead, which is
55
+ * the worst moment to find out. The sentence names the file, because the path is
56
+ * the thing that is wrong.
57
+ */
58
+ function tlsFor(tls) {
59
+ try {
60
+ return { cert: readFileSync(tls.cert), key: readFileSync(tls.key) };
61
+ }
62
+ catch (err) {
63
+ throw new Error(`the admin TLS certificate or key could not be read: ${err.message}`);
64
+ }
65
+ }
66
+ /**
67
+ * One request, from the address to the last byte.
68
+ *
69
+ * The order is the whole of the security story and it is not rearrangeable:
70
+ * **who is calling** (the address list), then **whether they may keep calling**
71
+ * (the lockout), then **whether they know the token**, and only then the route.
72
+ * Checking the token first would spend a comparison on a machine that was told
73
+ * not to knock, and counting a failure before the lockout was consulted would
74
+ * extend a lockout every time the locked-out caller tried again.
75
+ */
76
+ async function route(deps, gate, request, response) {
77
+ const started = Date.now();
78
+ const method = (request.method ?? 'GET').toUpperCase();
79
+ const path = (request.url ?? '/').split('?')[0] ?? '/';
80
+ const address = clientAddress(request, deps.config);
81
+ const done = (reply) => {
82
+ process.stderr.write(`funoteka admin: ${method} ${path} ${reply.status} ${Date.now() - started}ms\n`);
83
+ };
84
+ if (!gate.allowed(address)) {
85
+ const reply = { status: 403, body: { error: 'this address is not allowed' } };
86
+ record(deps, { method, path, address, status: reply.status, detail: { refused: 'address' } });
87
+ done(reply);
88
+ send(response, reply);
89
+ return;
90
+ }
91
+ if (gate.locked(address)) {
92
+ // Said as a time rather than as a rule, because the caller's next question is
93
+ // how long, and both `Retry-After` and the body answer it.
94
+ const seconds = gate.waitFor(address);
95
+ const reply = {
96
+ status: 429,
97
+ body: { error: `too many failed tokens; try again in ${seconds}s`, retryAfter: seconds },
98
+ };
99
+ record(deps, { method, path, address, status: reply.status, detail: { refused: 'lockout' } });
100
+ done(reply);
101
+ send(response, reply, { 'retry-after': String(seconds) });
102
+ return;
103
+ }
104
+ if (!authorised(request, deps.config.token)) {
105
+ gate.recordFailure(address);
106
+ const left = gate.failuresLeft(address);
107
+ const reply = {
108
+ status: 401,
109
+ body: left === undefined
110
+ ? { error: 'wrong or missing token' }
111
+ : { error: `wrong token; ${left} more before this address is locked out` },
112
+ };
113
+ // Written down, because a wrong token is either a typo or somebody who is
114
+ // not the operator, and a record that cannot tell the two apart is a record
115
+ // that answers nothing.
116
+ record(deps, { method, path, address, status: reply.status, detail: { refused: 'token' } });
117
+ done(reply);
118
+ send(response, reply);
119
+ return;
120
+ }
121
+ gate.recordSuccess(address);
122
+ const mutating = mutates(method);
123
+ const key = request.headers['idempotency-key'];
124
+ if (mutating && typeof key === 'string' && key !== '') {
125
+ const seen = recall(deps.db, key, method, path);
126
+ if (seen.kind === 'conflict') {
127
+ const reply = {
128
+ status: 422,
129
+ body: {
130
+ error: 'this Idempotency-Key was used for a different request',
131
+ was: `${seen.recorded.status}`,
132
+ },
133
+ detail: { conflict: key },
134
+ };
135
+ record(deps, { method, path, address, status: reply.status, detail: reply.detail });
136
+ done(reply);
137
+ send(response, reply);
138
+ return;
139
+ }
140
+ if (seen.kind === 'replay') {
141
+ const reply = { status: seen.recorded.status, body: JSON.parse(seen.recorded.body) };
142
+ // No audit line: nothing happened this time, and a second line saying it
143
+ // did would be the record lying about the work.
144
+ done(reply);
145
+ send(response, reply, { 'idempotent-replay': 'true' });
146
+ return;
147
+ }
148
+ }
149
+ // **The body is read once, here, whatever the route is.** A route that takes
150
+ // none has nothing to read, and one that takes some gets the text — which is
151
+ // also what lets the MCP transport ask a route a question without an HTTP
152
+ // request to ask it with.
153
+ const asked = await read(request, RESTORE_LIMIT);
154
+ const reply = await answer(deps, method, path, new URL(request.url ?? '/', 'http://localhost').searchParams, asked, address);
155
+ const detail = reply.detail;
156
+ // Attached before the answer is written, and fired by the socket's own
157
+ // `finish`: the difference between "this process is going down" and "your
158
+ // request was heard and this process is going down" is the whole of what a
159
+ // restart answers.
160
+ if (reply.after !== undefined)
161
+ response.once('finish', reply.after);
162
+ if (mutating) {
163
+ // **A refusal is recorded too, which is what `audit.ts` has always said.**
164
+ // It did not: the audit was written only when the answer was a success, so a
165
+ // `POST /user` refused for leaving nobody able to get in — the one refusal an
166
+ // operator most wants to find — left no line at all.
167
+ if (reply.status >= 400) {
168
+ recordMutation(deps, { method, path, address, status: reply.status, detail: reply.detail });
169
+ }
170
+ else {
171
+ // Before the answer goes out, and both of them. A record written after the
172
+ // reply is a record that a crash can lose *after* the caller was told the
173
+ // work was done — and then a retry does it twice, which is the whole of
174
+ // what this pair exists to prevent.
175
+ if (typeof key === 'string') {
176
+ remember(deps.db, key, method, path, reply.status, JSON.stringify(reply.body));
177
+ }
178
+ recordMutation(deps, { method, path, address, status: reply.status, detail });
179
+ }
180
+ }
181
+ done(reply);
182
+ send(response, reply);
183
+ }
184
+ /**
185
+ * The routes.
186
+ *
187
+ * A chain of `if`s rather than a table, and it has stopped being small: the
188
+ * surface grew a stage at a time (`task:2936`–`2938`) and each addition was one
189
+ * line, which is the argument for a chain — but a reader counting branches now
190
+ * finds thirty, and the "one place" this promised is a long one. A `Map` keyed by
191
+ * `${method} ${path}` is the shape it wants, and is worth doing when something
192
+ * next changes here rather than as a change of its own.
193
+ *
194
+ * The `path` is exact: the admin surface has no path parameters, and a route that
195
+ * guessed at one would be a route that answers something it was not asked.
196
+ */
197
+ async function answer(deps, method, path, query, asked,
198
+ // Who asked, carried for the one route whose work happens out of sight of
199
+ // `route`'s audit: MCP, which drives the same routes from inside this process.
200
+ address) {
201
+ if (path === '/health' && method === 'GET')
202
+ return healthRoute(deps);
203
+ if (path === '/status' && method === 'GET')
204
+ return status(deps);
205
+ if (path === '/config' && method === 'GET')
206
+ return readConfig(deps);
207
+ if (path === '/config' && method === 'POST')
208
+ return setConfig(deps, asked);
209
+ if (path === '/restart' && method === 'POST')
210
+ return restart(deps);
211
+ if (path === '/roots' && method === 'GET')
212
+ return { status: 200, body: { roots: listRoots(deps.db) } };
213
+ if (path === '/roots' && method === 'POST')
214
+ return addRootRoute(deps, asked);
215
+ if (path === '/roots' && method === 'DELETE')
216
+ return removeRootRoute(deps, asked);
217
+ if (path === '/scan' && method === 'GET')
218
+ return { status: 200, body: { ...deps.scanner.status(), modes: modes() } };
219
+ if (path === '/scan' && method === 'POST')
220
+ return startScan(deps, asked);
221
+ if (path === '/scan/cancel' && method === 'POST')
222
+ return cancelScan(deps);
223
+ if (path === '/scan/history' && method === 'GET')
224
+ return scanHistory(deps, query);
225
+ if (path === '/stats' && method === 'GET')
226
+ return { status: 200, body: stats(deps.db, deps.dbPath) };
227
+ if (path === '/issues' && method === 'GET')
228
+ return issuesRoute(deps, query);
229
+ if (path === '/logs' && method === 'GET')
230
+ return logsRoute(deps, query);
231
+ if (path === '/inventory' && method === 'GET')
232
+ return inventoryRoute(deps);
233
+ if (path === '/junk' && method === 'GET') {
234
+ return { status: 200, body: { hidden: hidden(deps.db), marks: marks(deps.db) } };
235
+ }
236
+ if (path === '/junk' && method === 'POST')
237
+ return markRoute(deps, asked);
238
+ if (path === '/junk' && method === 'DELETE')
239
+ return unmarkRoute(deps, asked);
240
+ if (path === '/playlists/import' && method === 'POST')
241
+ return importPlaylists(deps);
242
+ if (path === '/mcp' && method === 'POST')
243
+ return mcpRoute(deps, asked, address);
244
+ if (path === '/export' && method === 'GET') {
245
+ return { status: 200, body: exportState(deps.db, SCHEMA_VERSION) };
246
+ }
247
+ if (path === '/restore' && method === 'POST')
248
+ return restoreRoute(deps, asked);
249
+ if (path === '/user' && method === 'GET')
250
+ return readUser(deps);
251
+ if (path === '/user' && method === 'POST')
252
+ return setUser(deps, asked);
253
+ return { status: 404, body: { error: `no such admin route: ${method} ${path}` } };
254
+ }
255
+ /**
256
+ * What this server is and what it is doing.
257
+ *
258
+ * The question an operator asks from a phone before anything else: which build,
259
+ * how long it has been up, how big the collection is, whether a scan is running,
260
+ * and — the part they cannot see from outside — how the admin surface itself is
261
+ * configured. The token is not in it. The address list is, because knowing
262
+ * whether one is in force is the difference between a locked door and one that
263
+ * was never locked.
264
+ */
265
+ function status(deps) {
266
+ const { db, config } = deps;
267
+ const scan = scanStatus(db);
268
+ return {
269
+ status: 200,
270
+ body: {
271
+ server: 'funoteka',
272
+ version: SERVER_VERSION,
273
+ uptime: Math.round(process.uptime() * 10) / 10,
274
+ schema: SCHEMA_VERSION,
275
+ database: {
276
+ path: deps.dbPath,
277
+ songs: scan.count,
278
+ albums: count(db, "SELECT COUNT(*) AS n FROM album"),
279
+ artists: count(db, 'SELECT COUNT(*) AS n FROM artist'),
280
+ },
281
+ scan,
282
+ admin: {
283
+ port: config.port,
284
+ tls: config.tls === null ? false : true,
285
+ allow: config.allow === '' ? null : config.allow,
286
+ trustProxy: config.trustProxy,
287
+ supervised: config.supervised,
288
+ token: config.token === '' ? 'none' : 'set',
289
+ },
290
+ },
291
+ };
292
+ }
293
+ /**
294
+ * Every setting, in force and where it came from.
295
+ *
296
+ * Read from the file *now* rather than from what was loaded at startup: the
297
+ * operator may have edited it by hand a minute ago, and a report that showed the
298
+ * startup copy would be answering about a file that no longer exists.
299
+ */
300
+ function readConfig(deps) {
301
+ let file;
302
+ try {
303
+ file = readConfigFile(deps.configFile);
304
+ }
305
+ catch (err) {
306
+ // A file broken by hand is exactly when this route is reached for, so the
307
+ // sentence is the answer rather than a failure to answer.
308
+ return { status: 500, body: { error: err.message } };
309
+ }
310
+ return {
311
+ status: 200,
312
+ body: {
313
+ file: deps.configFile,
314
+ settings: configReport(deps.env, {}, file),
315
+ },
316
+ };
317
+ }
318
+ /**
319
+ * Write settings into the config file.
320
+ *
321
+ * **What this route is really for is not writing the file — it is the answer
322
+ * afterwards.** The value just written may be one the environment overrides, in
323
+ * which case it is not in force and will not be in force after a restart either;
324
+ * the report comes back with the answer so that the operator is told that the
325
+ * moment they do it, rather than finding out from a server that came up exactly
326
+ * as it was. An `overridden` entry is that fact, and it is the reason the
327
+ * `source` of a setting is recorded rather than guessed.
328
+ *
329
+ * Nothing here takes effect in the running process. The config is read once, at
330
+ * startup, by design — a server whose port could change under it mid-request
331
+ * would be a server whose behaviour depends on when you asked — so every write
332
+ * needs a restart, and the answer says so and names the route that does it.
333
+ */
334
+ async function setConfig(deps, asked) {
335
+ const parsed = parseBody(asked, false);
336
+ if (!parsed.ok)
337
+ return { status: 400, body: { error: parsed.error } };
338
+ const changes = parsed.value;
339
+ const keys = Object.keys(changes);
340
+ if (keys.length === 0) {
341
+ return { status: 400, body: { error: 'no settings were given' } };
342
+ }
343
+ // Credentials are not set through here, and the refusal says why rather than
344
+ // letting the write happen: a password in a config file is a password in a
345
+ // file, and the verb that owns rotating one should be the verb that decides
346
+ // where it is kept (`task:2938`).
347
+ const secret = keys.find((key) => SECRET_SETTINGS.has(key));
348
+ if (secret !== undefined) {
349
+ return {
350
+ status: 409,
351
+ body: {
352
+ error: `"${secret}" is a credential and is not set here`,
353
+ where: 'in the config file by hand, or in the environment — see DEPLOY.md',
354
+ },
355
+ };
356
+ }
357
+ // A key that is not a setting is refused before anything is written, with the
358
+ // same vocabulary the file itself is read by.
359
+ const unknown = keys.find((key) => SETTINGS[key] === undefined);
360
+ if (unknown !== undefined) {
361
+ return { status: 400, body: { error: `unknown setting "${unknown}"` } };
362
+ }
363
+ let written;
364
+ try {
365
+ writeConfigFile(deps.configFile, changes);
366
+ written = settingsNow(deps).settings;
367
+ }
368
+ catch (err) {
369
+ return { status: 400, body: { error: err.message } };
370
+ }
371
+ const of = (key) => written.find((one) => one.key === key);
372
+ const overridden = keys
373
+ .map((key) => of(key))
374
+ .filter((one) => one !== undefined && one.source !== 'file')
375
+ .map((one) => ({
376
+ key: one?.key,
377
+ value: one?.value,
378
+ source: one?.source,
379
+ note: one?.source === 'environment'
380
+ ? 'an environment variable is set and wins over the file — unset it for this to take effect'
381
+ : 'a command-line flag is set and wins over the file',
382
+ }));
383
+ return {
384
+ status: 200,
385
+ body: {
386
+ file: deps.configFile,
387
+ written: keys,
388
+ settings: written,
389
+ overridden,
390
+ restartRequired: true,
391
+ restart: 'POST /restart',
392
+ note: 'the config is read once, at startup: nothing here is in force until this process is restarted',
393
+ },
394
+ detail: {
395
+ // **What was written, not what is in force.** The two differ exactly when
396
+ // an environment variable overrides the file — and the audit line is a
397
+ // record of what somebody did, so a line reading `{"port": 4611}` after
398
+ // `config set port 7777` would say the opposite of the truth about the one
399
+ // case the operator needed the record for. What is in force is on the
400
+ // response, in `overridden`.
401
+ changed: Object.fromEntries(keys.map((key) => [key, changes[key] ?? null])),
402
+ overridden: overridden.map((one) => one.key),
403
+ },
404
+ };
405
+ }
406
+ /**
407
+ * Every setting, in force and where from — read the same way by every route that
408
+ * asks.
409
+ *
410
+ * Read from the file *now* rather than from what was loaded at startup: the
411
+ * operator may have edited it by hand a minute ago, and a report showing the
412
+ * startup copy would be answering about a file that no longer exists. `readConfig`
413
+ * is the one reader that does not come through here, and deliberately: it
414
+ * *answers* a file broken by hand rather than throwing, because a broken file is
415
+ * exactly when somebody reaches for it.
416
+ */
417
+ function settingsNow(deps) {
418
+ const settings = configReport(deps.env, {}, readConfigFile(deps.configFile));
419
+ return { settings, of: (key) => settings.find((one) => one.key === key) };
420
+ }
421
+ /**
422
+ * Who may listen: the credentials as *set or not* and where from, and the key
423
+ * registry. Never a secret itself.
424
+ */
425
+ function readUser(deps) {
426
+ const file = readConfigFile(deps.configFile);
427
+ const { of } = settingsNow(deps);
428
+ // **Whether a secret is set is asked of the loaded config, never of the
429
+ // report.** The report masks secrets — `value: null` for all three — which is
430
+ // what makes it safe to answer with, and it is also what would make a "set"
431
+ // computed from it read false for a deployment whose password is right there
432
+ // in the file. The first version of this did exactly that, and the test that
433
+ // asked a deployment with a password found it.
434
+ const effective = loadConfig(deps.env, {}, file);
435
+ return {
436
+ status: 200,
437
+ body: {
438
+ user: of('user')?.value ?? '',
439
+ // Reported as set-or-not and where from; never as itself. A password read
440
+ // back over HTTP is a password in a shell history and in whatever logs the
441
+ // response.
442
+ password: { set: effective.password !== '', source: of('password')?.source },
443
+ apiKey: { set: effective.apiKey !== '', source: of('apiKey')?.source },
444
+ registry: listKeys(deps.db).map((key) => ({
445
+ id: key.id,
446
+ label: key.label,
447
+ createdAt: key.createdAt,
448
+ revokedAt: key.revokedAt,
449
+ })),
450
+ where: 'the password and the bootstrap key live in the config file or the environment; the registry lives in the meta layer',
451
+ },
452
+ };
453
+ }
454
+ /**
455
+ * Change who may listen.
456
+ *
457
+ * **This is the verb `config set` refuses to be.** A credential written through
458
+ * a general settings route would be a credential written by accident; here it is
459
+ * the whole subject, and the route is built around the three questions that
460
+ * matter: does the change leave anybody able to get in, is it the environment
461
+ * that has the last word, and has the deployment been told it must be restarted.
462
+ *
463
+ * `POST /user {"rotate": "apiKey"}` generates one and shows it **once** — the
464
+ * only moment it is ever readable through this surface — and writes it into the
465
+ * config file, whose permissions are the deployment's business and which the
466
+ * response names.
467
+ */
468
+ async function setUser(deps, asked) {
469
+ const parsed = parseBody(asked, false);
470
+ if (!parsed.ok)
471
+ return { status: 400, body: { error: parsed.error } };
472
+ const changes = {};
473
+ const given = parsed.value;
474
+ for (const key of ['user', 'password', 'apiKey']) {
475
+ const value = given[key];
476
+ if (value === undefined)
477
+ continue;
478
+ if (typeof value !== 'string' || value === '') {
479
+ return { status: 400, body: { error: `"${key}" is a value, and an empty one is not one` } };
480
+ }
481
+ changes[key] = value;
482
+ }
483
+ const rotated = given.rotate;
484
+ let revealed = null;
485
+ if (rotated !== undefined) {
486
+ if (rotated !== 'apiKey') {
487
+ return { status: 400, body: { error: '"rotate" is "apiKey" — rotate nothing else, and set the rest' } };
488
+ }
489
+ if (given.apiKey !== undefined) {
490
+ return { status: 400, body: { error: 'give either "apiKey" or "rotate", not both' } };
491
+ }
492
+ const minted = newSecret();
493
+ changes.apiKey = minted;
494
+ revealed = { what: 'apiKey', value: minted };
495
+ }
496
+ if (Object.keys(changes).length === 0) {
497
+ return { status: 400, body: { error: 'nothing to change: user, password, apiKey or rotate' } };
498
+ }
499
+ // **The check this route exists for.** A server with no password and no key
500
+ // does not start at all (`api/server.ts` refuses to build one), so a rotation
501
+ // that removed the last way in would be a change that takes the music down at
502
+ // the next restart — refused now, while the operator is looking at the answer.
503
+ const prospective = { ...readConfigFile(deps.configFile), ...changes };
504
+ const after = loadConfig(deps.env, {}, prospective);
505
+ if (after.password === '' && after.apiKey === '') {
506
+ return {
507
+ status: 409,
508
+ body: {
509
+ error: 'that would leave this server with no way in, and it would not start',
510
+ hint: 'set a password or an apiKey, or unset nothing if the environment is what holds them',
511
+ },
512
+ };
513
+ }
514
+ let written;
515
+ try {
516
+ writeConfigFile(deps.configFile, changes);
517
+ written = settingsNow(deps).settings;
518
+ }
519
+ catch (err) {
520
+ return { status: 400, body: { error: err.message } };
521
+ }
522
+ const of = (key) => written.find((one) => one.key === key);
523
+ const overridden = Object.keys(changes)
524
+ .map((key) => of(key))
525
+ .filter((one) => one !== undefined && one.source !== 'file')
526
+ .map((one) => ({ key: one?.key, source: one?.source,
527
+ note: 'the environment is set and wins over the file — the rotation will not take effect until it is unset' }));
528
+ return {
529
+ status: 200,
530
+ body: {
531
+ file: deps.configFile,
532
+ changed: Object.keys(changes),
533
+ ...(revealed === null ? {} : { revealed }),
534
+ overridden,
535
+ restartRequired: true,
536
+ restart: 'POST /restart',
537
+ note: 'the file is now the deployment\'s secret store for these — its permissions are yours to keep, and a copy of it is a copy of a password',
538
+ },
539
+ detail: { changed: Object.keys(changes).map((key) => `${key} set`), overridden: overridden.map((one) => one.key) },
540
+ };
541
+ }
542
+ /**
543
+ * Liveness on the admin port, which the contract asks for beside `status`.
544
+ *
545
+ * The same check the music port answers (`health.ts`) — the process is up and
546
+ * its meta layer is readable — and it is here because an operator checking a
547
+ * deployment from outside reaches this port first: the music port may be behind
548
+ * a firewall a supervisor is not.
549
+ */
550
+ function healthRoute(deps) {
551
+ const answer = health(deps.db);
552
+ return { status: answer.status, body: JSON.parse(answer.body) };
553
+ }
554
+ /** What the library is, in numbers the scanner already wrote down. */
555
+ function issuesRoute(deps, query) {
556
+ const params = query;
557
+ const limit = whole(params.get('limit'), 100, 1, 1000);
558
+ if (limit === null)
559
+ return { status: 400, body: { error: '"limit" is a number from 1 to 1000' } };
560
+ // No severity means all of them: the question this route answers first is
561
+ // "what went wrong", and a filter nobody asked for would answer a narrower one.
562
+ const severity = params.get('severity') ?? undefined;
563
+ return { status: 200, body: issues(deps.db, limit, severity) };
564
+ }
565
+ function logsRoute(deps, query) {
566
+ const params = query;
567
+ const lines = whole(params.get('lines'), 200, 1, 5000);
568
+ if (lines === null)
569
+ return { status: 400, body: { error: '"lines" is a number from 1 to 5000' } };
570
+ return { status: 200, body: logs(deps.logFile, deps.dbPath, lines) };
571
+ }
572
+ /**
573
+ * The classified collection as the CLI dumps it.
574
+ *
575
+ * **The same text, and not a JSON form of it.** `funoteka inventory` exists to be
576
+ * read by a person looking at a library that came out wrong, and a second
577
+ * rendering of it for HTTP would be a second thing to keep in step with the
578
+ * stages — with the difference showing up as an admin API that describes a
579
+ * library nobody has. It is text/plain, and it is as long as the collection is.
580
+ */
581
+ function inventoryRoute(deps) {
582
+ const dump = inventoryDump(deps.db, { dbPath: deps.dbPath });
583
+ return { status: 200, body: { inventory: dump }, text: dump };
584
+ }
585
+ /**
586
+ * What the filter is keeping out, and the edits a person made by hand.
587
+ *
588
+ * Two lists and not one, because they answer different questions: `hidden` is
589
+ * what a client will not see, and `marks` is what somebody decided. An `allow`
590
+ * on a folder the rule would have hidden keeps nothing out and appears only in
591
+ * the second — and it is exactly the mark an operator needs to find again to
592
+ * take back.
593
+ */
594
+ async function markRoute(deps, asked) {
595
+ const parsed = parseBody(asked, false);
596
+ if (!parsed.ok)
597
+ return { status: 400, body: { error: parsed.error } };
598
+ const { path, verdict, note } = parsed.value;
599
+ // **Both vocabularies, one meaning.** The contract says `junk list/allow/
600
+ // block/remove`; this surface says `verdict: junk|trust`, which is what the
601
+ // database column has always been called. A caller reading the contract and a
602
+ // caller reading `GET /junk` should not have to translate between them, so
603
+ // `block` and `allow` are accepted as the same two verdicts — and the answer
604
+ // says which word it understood.
605
+ const wanted = verdict === 'block' ? 'junk' : verdict === 'allow' ? 'trust' : verdict;
606
+ if (typeof path !== 'string' || path === '') {
607
+ return { status: 400, body: { error: '"path" is required: the folder to decide about' } };
608
+ }
609
+ if (wanted !== 'junk' && wanted !== 'trust') {
610
+ return {
611
+ status: 400,
612
+ body: {
613
+ error: '"verdict" is junk or trust, and the contract’s block or allow mean the same two',
614
+ junk: 'not a record — keep it out of every listing',
615
+ trust: 'a record — serve it, whatever the rule says',
616
+ same: { block: 'junk', allow: 'trust' },
617
+ },
618
+ };
619
+ }
620
+ if (note !== undefined && typeof note !== 'string') {
621
+ return { status: 400, body: { error: '"note" is a sentence, for whoever reads this back' } };
622
+ }
623
+ const where = resolvePath(deps.db, path);
624
+ if (where === undefined) {
625
+ // A path under no root is not an error about the collection: it is a path
626
+ // this server has never heard of, and saying so beats marking something else.
627
+ return { status: 404, body: { error: `no configured root contains that path: ${path}` } };
628
+ }
629
+ try {
630
+ const reason = mark(deps.db, where.rootId, where.relPath, wanted, note ?? null);
631
+ return {
632
+ status: 200,
633
+ body: {
634
+ path,
635
+ verdict: wanted,
636
+ reason,
637
+ hidden: reason !== null,
638
+ note: 'the album was re-derived now — a rescan is not needed for this to take effect',
639
+ },
640
+ detail: { marked: path, verdict: wanted },
641
+ };
642
+ }
643
+ catch (err) {
644
+ // The store's own sentence: a path with no album under it is a statement
645
+ // about nothing, and the typo has to reach the operator.
646
+ return { status: 400, body: { error: err.message } };
647
+ }
648
+ }
649
+ /** Take a hand edit back, and let the rule decide again. */
650
+ async function unmarkRoute(deps, asked) {
651
+ const parsed = parseBody(asked, false);
652
+ if (!parsed.ok)
653
+ return { status: 400, body: { error: parsed.error } };
654
+ const path = parsed.value.path;
655
+ if (typeof path !== 'string' || path === '') {
656
+ return { status: 400, body: { error: '"path" is required: the folder to hand back to the rule' } };
657
+ }
658
+ const where = resolvePath(deps.db, path);
659
+ if (where === undefined) {
660
+ return { status: 404, body: { error: `no configured root contains that path: ${path}` } };
661
+ }
662
+ try {
663
+ const reason = unmark(deps.db, where.rootId, where.relPath);
664
+ return {
665
+ status: 200,
666
+ body: {
667
+ path,
668
+ reason,
669
+ hidden: reason !== null,
670
+ note: 'the rule decides this folder again, as it did before anybody marked it',
671
+ },
672
+ detail: { unmarked: path },
673
+ };
674
+ }
675
+ catch (err) {
676
+ return { status: 400, body: { error: err.message } };
677
+ }
678
+ }
679
+ /**
680
+ * Read the collection's `.m3u` files again.
681
+ *
682
+ * **The one stage short enough to run inside a request, and the number is
683
+ * measured rather than argued.** On the live collection — 472 albums, 5054
684
+ * tracks, 27 `.m3u` files — this takes **80 ms**, and 220 ms on the first run of
685
+ * a process, which is the cache being cold. A scan is a process of its own
686
+ * because a walk of that same collection is tens of seconds on the one thread
687
+ * that answers everybody; this reads no audio at all, which is where a scan's
688
+ * time goes, and it did not read even the playlist files: `filesRead: 0` on all
689
+ * three runs, because the stage keeps what each file said (`playlist_source_file`)
690
+ * and only re-reads one that changed.
691
+ *
692
+ * **The limit of that claim, stated rather than implied:** 80 ms of one frozen
693
+ * thread and of the write lock is a hiccup, and a collection with thousands of
694
+ * playlist files instead of 27 is not what this was measured against. If one
695
+ * turns up, this becomes another process, the way the scan is.
696
+ */
697
+ function importPlaylists(deps) {
698
+ const counters = applyPlaylists(deps.db);
699
+ return {
700
+ status: 200,
701
+ body: { ...counters, note: 'a playlist a client made through the API is untouched — this reads the .m3u files on disk' },
702
+ detail: { imported: counters.imported, filesRead: counters.filesRead },
703
+ };
704
+ }
705
+ /** A query parameter that has to be a whole number inside a range, or nothing. */
706
+ function whole(value, fallback, least, most) {
707
+ if (value === null)
708
+ return fallback;
709
+ const number = Number(value);
710
+ return Number.isInteger(number) && number >= least && number <= most ? number : null;
711
+ }
712
+ /**
713
+ * MCP, on the port that already has the token.
714
+ *
715
+ * **The HTTP transport of the protocol, and it is the same listener as
716
+ * everything else** — same token, same address list, same lockout, same audit.
717
+ * A third port for agents would be a third thing to keep locked, and the door
718
+ * this one opens is the one an operator already watches.
719
+ *
720
+ * The tools are called *in this process*, through the same dispatcher the HTTP
721
+ * routes go through: a tool call is an ordinary admin request with the request
722
+ * left out. That is not a shortcut either — it is the only way the audit, the
723
+ * idempotency record and the refusals can be the same ones, and it is why a
724
+ * tool that changes something appears in the audit file exactly as `curl` does.
725
+ *
726
+ * A notification — `notifications/initialized` — has no answer, and 202 is what
727
+ * this says to one. A client that treated that as a failure would be reading the
728
+ * protocol's own silence as an error.
729
+ */
730
+ async function mcpRoute(deps, asked, address) {
731
+ if (asked === null) {
732
+ return { status: 400, body: { error: 'an MCP message is required: one JSON-RPC object' } };
733
+ }
734
+ let message;
735
+ try {
736
+ message = JSON.parse(asked);
737
+ }
738
+ catch (err) {
739
+ return { status: 400, body: { error: `the body is not JSON: ${err.message}` } };
740
+ }
741
+ const inProcess = async (method, path, body, idempotencyKey) => {
742
+ const url = new URL(path, 'http://localhost');
743
+ const key = idempotencyKey ?? '';
744
+ // **The same recall `route` does, for the same reason, and it was missing.**
745
+ // A tool call never reaches `route` — that is the whole point of the design
746
+ // above — and the idempotency check lived only there, so the surface's claim
747
+ // that mutations are idempotent held for callers with `curl` and not for the
748
+ // agents this transport exists for. `funoteka_user_set {"rotate":"apiKey"}`
749
+ // is where it bites: a repeat minted a second key and invalidated the one
750
+ // the first call had returned. Found by a test that asked for one twice.
751
+ if (mutates(method) && key !== '') {
752
+ const seen = recall(deps.db, key, method, url.pathname);
753
+ if (seen.kind === 'conflict') {
754
+ return {
755
+ status: 422,
756
+ body: {
757
+ error: 'this Idempotency-Key was used for a different request',
758
+ was: `${seen.recorded.status}`,
759
+ },
760
+ };
761
+ }
762
+ // No audit line here either, and for the reason `route` gives: nothing
763
+ // happened this time, and a second line saying it did is the record lying
764
+ // about the work.
765
+ if (seen.kind === 'replay') {
766
+ return { status: seen.recorded.status, body: JSON.parse(seen.recorded.body) };
767
+ }
768
+ }
769
+ const reply = await answer(deps, method, url.pathname, url.searchParams, body === undefined ? null : JSON.stringify(body), address);
770
+ // Kept before the answer goes back, and only a success, exactly as `route`
771
+ // keeps it: a record written after the reply is one a crash can lose *after*
772
+ // the caller was told the work was done, and then a retry does it twice.
773
+ if (mutates(method) && key !== '' && reply.status < 400) {
774
+ remember(deps.db, key, method, url.pathname, reply.status, JSON.stringify(reply.body));
775
+ }
776
+ // **A mutation asked for through MCP is written down here, because this is
777
+ // where it happens.** The audit lives in `route`, which a tool call never
778
+ // reaches — so without this, the one claim this whole file makes about MCP
779
+ // ("the same audit line as `curl`") would have been false, and the operator
780
+ // would find out by reading an audit file that was missing an event. Found
781
+ // by the test that asks for one.
782
+ if (reply.status < 400 && reply.detail === undefined)
783
+ return { status: reply.status, body: reply.body };
784
+ recordMutation(deps, { method, path: url.pathname, address, status: reply.status, detail: reply.detail }, true);
785
+ return { status: reply.status, body: reply.body };
786
+ };
787
+ const answered = await handleMessage(inProcess, message);
788
+ if (answered === null) {
789
+ return { status: 202, body: { ok: true, note: 'a notification: nothing to answer' } };
790
+ }
791
+ return { status: 200, body: answered };
792
+ }
793
+ /**
794
+ * Write a document back, and say what it could not place.
795
+ *
796
+ * The body is the object `GET /export` answers with, sent back — a backup is
797
+ * worth nothing that cannot be read in, and the shape is its own contract. What
798
+ * comes back is a count of what was written and a list of what the library could
799
+ * not answer about: an entry naming a song that is no longer on disk is left
800
+ * out, and left out *loudly*, because a restore that silently dropped a third of
801
+ * a playlist is indistinguishable from one that worked.
802
+ */
803
+ async function restoreRoute(deps, asked) {
804
+ const parsed = parseBody(asked, false, RESTORE_LIMIT);
805
+ if (!parsed.ok)
806
+ return { status: 400, body: { error: parsed.error } };
807
+ if (!isExport(parsed.value)) {
808
+ return {
809
+ status: 400,
810
+ body: {
811
+ error: 'that is not an export document',
812
+ expected: 'the object GET /export answers with — `funoteka: "export"` and a version',
813
+ },
814
+ };
815
+ }
816
+ const { placed, skipped } = restoreState(deps.db, parsed.value);
817
+ return {
818
+ status: 200,
819
+ body: {
820
+ placed,
821
+ note: 'placed, not inserted: these are in place now, whether they were already there or not',
822
+ // A document from a large library can name thousands of songs that have
823
+ // moved since; fifty is what fits in an answer a person reads, and the
824
+ // count of the rest is the part that must not be lost.
825
+ skipped: skipped.slice(0, 50),
826
+ skippedMore: Math.max(0, skipped.length - 50),
827
+ merge: 'what this library already had was left alone',
828
+ },
829
+ detail: { placed },
830
+ };
831
+ }
832
+ /**
833
+ * How large a restore may be, and why it is not the same number as everything
834
+ * else.
835
+ *
836
+ * Every other route here takes a handful of settings — 64 KB is generous for
837
+ * them, and a body larger than that is a mistake worth refusing. A backup is the
838
+ * opposite: it is as large as the person's own decisions, it arrives in one
839
+ * piece, and there is no smaller way to send it.
840
+ */
841
+ const RESTORE_LIMIT = 16 * 1024 * 1024;
842
+ /**
843
+ * The scan modes, described rather than merely enumerated.
844
+ *
845
+ * The caller here is as often an agent as a person, and "incremental" alone does
846
+ * not say what it is incremental *about*. What decides is size and mtime, which
847
+ * is a fact about this scanner worth stating where the choice is offered.
848
+ */
849
+ function modes() {
850
+ return [
851
+ {
852
+ name: 'incremental',
853
+ what: 'read what has moved since the last run — a file whose size and modification time are unchanged is left alone',
854
+ },
855
+ {
856
+ name: 'full',
857
+ what: 'read every file again, whether or not it looks unchanged — for when the ledger is the wrong question',
858
+ },
859
+ ];
860
+ }
861
+ /** Configure a directory as something this deployment reads. */
862
+ async function addRootRoute(deps, asked) {
863
+ const parsed = parseBody(asked, false);
864
+ if (!parsed.ok)
865
+ return { status: 400, body: { error: parsed.error } };
866
+ const path = parsed.value.path;
867
+ if (typeof path !== 'string' || path === '') {
868
+ return { status: 400, body: { error: '"path" is required: the directory to read' } };
869
+ }
870
+ try {
871
+ const { root, already } = addRoot(deps.db, path);
872
+ return {
873
+ status: already ? 200 : 201,
874
+ body: {
875
+ root,
876
+ // A shelf somebody thought they were adding and did not is worth saying
877
+ // so: the alternative is a listing that grew by nothing and an operator
878
+ // wondering whether the call worked.
879
+ already,
880
+ scan: 'POST /scan reads it',
881
+ },
882
+ detail: { added: root.path, already },
883
+ };
884
+ }
885
+ catch (err) {
886
+ // The store's own sentence, which names the path: a root that is not a
887
+ // directory is the one mistake this route exists to catch while the operator
888
+ // is still looking at the answer.
889
+ return { status: 400, body: { error: err.message } };
890
+ }
891
+ }
892
+ /**
893
+ * Stop reading a directory, and take what came from it out of the library.
894
+ *
895
+ * The destructive one, and the answer carries the count for the reason the store
896
+ * does: "removed" and "removed a third of your library" are the same word and
897
+ * not the same event. The files on disk are untouched.
898
+ */
899
+ async function removeRootRoute(deps, asked) {
900
+ const parsed = parseBody(asked, false);
901
+ if (!parsed.ok)
902
+ return { status: 400, body: { error: parsed.error } };
903
+ const path = parsed.value.path;
904
+ if (typeof path !== 'string' || path === '') {
905
+ return { status: 400, body: { error: '"path" is required: the directory to stop reading' } };
906
+ }
907
+ const removed = removeRoot(deps.db, path);
908
+ if (removed === null) {
909
+ return { status: 404, body: { error: `no such root: ${path}` } };
910
+ }
911
+ return {
912
+ status: 200,
913
+ body: {
914
+ removed: removed.root.path,
915
+ songs: removed.songs,
916
+ albums: removed.albums,
917
+ note: 'the files on disk were not touched — this is what the server reads, not what it holds',
918
+ },
919
+ detail: { removed: removed.root.path, songs: removed.songs, albums: removed.albums },
920
+ };
921
+ }
922
+ /**
923
+ * Start a scan of everything this deployment is configured to read.
924
+ *
925
+ * **202, because the work has not happened yet.** The answer is that a process
926
+ * was started and where to watch it; a caller that waited for a scan would hold
927
+ * a connection open for the length of a walk, and one answered 200 would
928
+ * reasonably read that as "the library has been read".
929
+ */
930
+ async function startScan(deps, asked) {
931
+ const parsed = parseBody(asked, true);
932
+ if (!parsed.ok)
933
+ return { status: 400, body: { error: parsed.error } };
934
+ const roots = listRoots(deps.db);
935
+ if (roots.length === 0) {
936
+ return { status: 409, body: { error: 'no roots are configured — POST /roots adds one' } };
937
+ }
938
+ const wanted = parsed.value.mode ?? 'incremental';
939
+ if (wanted !== 'incremental' && wanted !== 'full') {
940
+ return { status: 400, body: { error: '"mode" is either incremental or full', modes: modes() } };
941
+ }
942
+ const started = deps.scanner.start(wanted);
943
+ if (!started.ok)
944
+ return { status: 409, body: { error: started.reason } };
945
+ return {
946
+ status: 202,
947
+ body: {
948
+ started: { pid: started.pid, mode: wanted, roots: roots.map((one) => one.path) },
949
+ watch: 'GET /scan',
950
+ cancel: 'POST /scan/cancel',
951
+ },
952
+ detail: { scan: { pid: started.pid, mode: wanted } },
953
+ };
954
+ }
955
+ /**
956
+ * Stop the scan that is running, or settle the record of one that is not.
957
+ *
958
+ * Two different things wearing one verb, and the answer says which happened: a
959
+ * scan this process started is killed, and a run left `running` by a process
960
+ * that has since gone is written down as cancelled. The second is the way out of
961
+ * a row that would otherwise refuse every later scan — and it is *marked* rather
962
+ * than assumed, because the alternative is a server reporting a scan running for
963
+ * ever.
964
+ */
965
+ function cancelScan(deps) {
966
+ const cancelled = deps.scanner.cancel();
967
+ if (!cancelled.ok)
968
+ return { status: 409, body: { error: cancelled.reason } };
969
+ if (cancelled.settled !== null) {
970
+ return {
971
+ status: 200,
972
+ body: {
973
+ settledRun: cancelled.settled,
974
+ note: 'that run was not started by this process — it is now recorded as cancelled',
975
+ },
976
+ detail: { settledRun: cancelled.settled },
977
+ };
978
+ }
979
+ return {
980
+ status: 200,
981
+ body: {
982
+ stopping: true,
983
+ note: 'the scan was killed; its run is recorded as cancelled when the process is gone',
984
+ },
985
+ detail: { stopped: true },
986
+ };
987
+ }
988
+ /** What the last few scans did, newest first. */
989
+ function scanHistory(deps, query) {
990
+ const limit = whole(query.get('limit'), 20, 1, 200);
991
+ if (limit === null)
992
+ return { status: 400, body: { error: '"limit" is a number from 1 to 200' } };
993
+ return { status: 200, body: { runs: deps.scanner.history(limit) } };
994
+ }
995
+ /**
996
+ * A request body, parsed as the JSON object every mutating route here takes.
997
+ *
998
+ * One parser for all of them, so that "the body must be a JSON object" is one
999
+ * sentence rather than four, and so that a route that takes no body at all says
1000
+ * so by asking for an empty one to be allowed rather than by not parsing.
1001
+ */
1002
+ function parseBody(text, allowEmpty, cap = MAX_BODY) {
1003
+ if (text !== null && text.length > cap) {
1004
+ return { ok: false, error: `the body is larger than ${cap} bytes` };
1005
+ }
1006
+ if (text === null) {
1007
+ return allowEmpty
1008
+ ? { ok: true, value: {} }
1009
+ : { ok: false, error: 'a JSON object is required in the body' };
1010
+ }
1011
+ let parsed;
1012
+ try {
1013
+ parsed = JSON.parse(text);
1014
+ }
1015
+ catch (err) {
1016
+ return { ok: false, error: `the body is not JSON: ${err.message}` };
1017
+ }
1018
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1019
+ return { ok: false, error: 'the body must be a JSON object' };
1020
+ }
1021
+ return { ok: true, value: parsed };
1022
+ }
1023
+ /**
1024
+ * A restart, which is an exit and a promise that something starts this again.
1025
+ *
1026
+ * **The promise is checked rather than assumed.** Where nothing supervises this
1027
+ * process — a bare `serve --daemon`, a terminal someone left — "restart" can only
1028
+ * mean "stop", and a route that answered that with a cheerful 200 would be a
1029
+ * control surface that takes the music down when asked to bring it back. So the
1030
+ * supervision is stated by whoever provides it (`FUNOTEKA_SUPERVISED=1`: the
1031
+ * compose file, the unit, the service wrapper) and a server that was not told is
1032
+ * refused, with the name of the setting in the refusal.
1033
+ */
1034
+ function restart(deps) {
1035
+ if (!deps.config.supervised) {
1036
+ return {
1037
+ status: 409,
1038
+ body: {
1039
+ error: 'nothing supervises this process, so a restart would only stop it. Start it under the ' +
1040
+ 'service wrapper, or set FUNOTEKA_SUPERVISED=1 if something does start it again',
1041
+ },
1042
+ };
1043
+ }
1044
+ process.stderr.write('funoteka admin: restarting\n');
1045
+ return {
1046
+ status: 200,
1047
+ body: {
1048
+ restarting: true,
1049
+ note: 'this process is exiting; whatever supervises it starts it again',
1050
+ },
1051
+ detail: { restarting: true },
1052
+ after: () => deps.onRestart(),
1053
+ };
1054
+ }
1055
+ /**
1056
+ * Whether the caller knows the token.
1057
+ *
1058
+ * **Compared through `sameSecret`, which is the comparison this server already
1059
+ * makes for a password** — hashed on both sides, so the time it takes says
1060
+ * nothing about how nearly the guess matched or how long the token is. A second
1061
+ * copy of that reasoning living here would be a second copy to get wrong the
1062
+ * next time it is touched, and the primitive is not the interesting part of this
1063
+ * file.
1064
+ *
1065
+ * `Bearer` is the only accepted spelling. A token in a query string would end up
1066
+ * in a log line, and every log line is a file somebody copies around.
1067
+ */
1068
+ function authorised(request, token) {
1069
+ const header = request.headers.authorization ?? '';
1070
+ const given = /^Bearer[ \t]+(.+)$/iu.exec(header.trim())?.[1]?.trim();
1071
+ if (given === undefined || given === '' || token === '')
1072
+ return false;
1073
+ return sameSecret(given, token);
1074
+ }
1075
+ /**
1076
+ * Where the request came from, as far as it can be trusted.
1077
+ *
1078
+ * The socket's own address, unless a proxy has been declared: behind one, every
1079
+ * request arrives from the proxy and an address list would be a list of the
1080
+ * proxy. `trustProxy` is a statement about the deployment — that the port is
1081
+ * reachable *only* through that proxy — and it is off by default for exactly
1082
+ * that reason: the header is written by the caller, so believing it on a port
1083
+ * that can be reached directly means anyone can claim any address.
1084
+ *
1085
+ * The *last* entry of the forwarded chain is the one taken. A proxy appends the
1086
+ * address it saw to whatever the client sent, so the last is the one the nearest
1087
+ * trusted hop observed; taking the first would be reading a value the client
1088
+ * chose.
1089
+ */
1090
+ function clientAddress(request, config) {
1091
+ const direct = request.socket.remoteAddress ?? '';
1092
+ if (!config.trustProxy)
1093
+ return direct;
1094
+ const forwarded = request.headers['x-forwarded-for'];
1095
+ const chain = (Array.isArray(forwarded) ? forwarded.join(',') : (forwarded ?? ''))
1096
+ .split(',')
1097
+ .map((part) => part.trim())
1098
+ .filter((part) => part !== '');
1099
+ return chain.at(-1) ?? direct;
1100
+ }
1101
+ /** The body of a request, or null when it is missing or larger than any setting could be. */
1102
+ const MAX_BODY = 64 * 1024;
1103
+ async function read(request, cap = MAX_BODY) {
1104
+ const chunks = [];
1105
+ let length = 0;
1106
+ for await (const chunk of request) {
1107
+ const buffer = chunk;
1108
+ length += buffer.length;
1109
+ if (length > cap) {
1110
+ request.destroy();
1111
+ return null;
1112
+ }
1113
+ chunks.push(buffer);
1114
+ }
1115
+ const text = Buffer.concat(chunks).toString('utf8');
1116
+ return text === '' ? null : text;
1117
+ }
1118
+ /** Whether a method is one that changes something, and so is worth a record. */
1119
+ function mutates(method) {
1120
+ return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
1121
+ }
1122
+ /**
1123
+ * Write the record of a mutation, or of a refusal — the one rule, in one place.
1124
+ *
1125
+ * It was written twice: once for a request that arrived over the wire and once
1126
+ * for a tool call MCP makes inside this process, and the two had to be kept in
1127
+ * step by hand. A mutation with nothing to say about itself — a `restart`, whose
1128
+ * `detail` is its own line in the log — is still recorded, as a refusal is: what
1129
+ * a route has to say is not what makes it an event.
1130
+ */
1131
+ function recordMutation(deps, entry,
1132
+ /** Only the in-process path has nothing of its own to say. */
1133
+ whenSilent = false) {
1134
+ if (entry.detail === undefined && !whenSilent)
1135
+ return;
1136
+ record(deps, { ...entry, detail: entry.detail ?? { refused: entry.status >= 400 } });
1137
+ }
1138
+ /** Write the record of a mutation, or of a refusal that got as far as the gate. */
1139
+ function record(deps, entry) {
1140
+ deps.audit({ at: new Date().toISOString(), ...entry });
1141
+ }
1142
+ /**
1143
+ * The answer, as it goes on the wire.
1144
+ *
1145
+ * `no-store` on every one of them: a cached answer about a server's own state is
1146
+ * an answer about the state it was in, and a proxy that replayed one would tell
1147
+ * the next caller what the previous one did.
1148
+ */
1149
+ function send(response, reply, headers = {}) {
1150
+ if (response.headersSent) {
1151
+ response.destroy();
1152
+ return;
1153
+ }
1154
+ const text = reply.text ?? `${JSON.stringify(reply.body)}\n`;
1155
+ response.writeHead(reply.status, {
1156
+ 'content-type': reply.text === undefined ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8',
1157
+ 'content-length': Buffer.byteLength(text),
1158
+ 'cache-control': 'no-store',
1159
+ ...headers,
1160
+ });
1161
+ response.end(text);
1162
+ }