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.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- 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
|
+
}
|