cursedbelt-server 4.42.0 → 4.43.1
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/README.md +2 -1
- package/dist/server/youtube/youtubeApi.d.ts +93 -0
- package/dist/server/youtube/youtubeApi.js +219 -0
- package/dist/server/youtube/ytdlp.d.ts +129 -0
- package/dist/server/youtube/ytdlp.js +334 -0
- package/docs/ctgr.md +24 -0
- package/docs/skipped-tests.md +10 -10
- package/package.json +14 -2
- package/src/cursedopsRange.spec.ts +26 -0
- package/src/leafSubpathsImportNothing.spec.ts +18 -0
- package/src/server/storage/binaryServerProcessing.integration.spec.ts +31 -11
- package/src/server/storage/chunked.integration.spec.ts +6 -2
- package/src/server/storage/fileUploadClient-chunked.integration.spec.ts +6 -2
- package/src/server/storage/transcode.integration.spec.ts +31 -10
- package/src/server/youtube/youtubeApi.spec.ts +90 -0
- package/src/server/youtube/youtubeApi.ts +325 -0
- package/src/server/youtube/ytdlp.spec.ts +259 -0
- package/src/server/youtube/ytdlp.ts +454 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/youtube/ytdlp` — run yt-dlp on the Mac and hand back the file it made.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it is here (forge task 2349) ────────────────────────────────────────────────────
|
|
5
|
+
* Music downloads the owner's YouTube playlists as AUDIO (task 2348) and Collections downloads
|
|
6
|
+
* them as VIDEO (task 2349). Both runners were the same machine with a different `-f`: spawn
|
|
7
|
+
* yt-dlp, read its progress off a marker line, sort a failure by what fixes it, install the
|
|
8
|
+
* binary when it is missing and update it when YouTube has moved on. So that machine is here,
|
|
9
|
+
* once, and each app hands it a {@link YtDlpProfile} — the format, the size ceiling, the noun
|
|
10
|
+
* its sentences use — plus the one command that installs or updates yt-dlp from that app.
|
|
11
|
+
*
|
|
12
|
+
* What is NOT here, on purpose: where the binary lives (each app passes `binary`, normally
|
|
13
|
+
* {@link ytDlpPath} of the generation's state root), what a downloaded file becomes (Music files
|
|
14
|
+
* a track; Collections ingests a video), and any re-encode (only Collections has one, and only
|
|
15
|
+
* when no H.264 stream exists).
|
|
16
|
+
*
|
|
17
|
+
* ── The file stays on disk ──────────────────────────────────────────────────────────────
|
|
18
|
+
* A download resolves to a PATH inside a fresh temporary directory, and the caller disposes of
|
|
19
|
+
* it. A 1080p video is gigabytes, and reading it into memory to hand it over would make the
|
|
20
|
+
* agent's footprint the size of the largest video in a playlist. Music, whose files are a few
|
|
21
|
+
* MB, reads the bytes and disposes at once.
|
|
22
|
+
*
|
|
23
|
+
* ── Self-healing in the two ways that need no owner ─────────────────────────────────────
|
|
24
|
+
* A missing binary is installed before the first run, and a failure that reads as "this
|
|
25
|
+
* yt-dlp is out of date" updates it (at most once an hour) and tries again. What is left after
|
|
26
|
+
* that is a sentence that NAMES the fix — "yt-dlp is out of date … Run: cd … && bun run yt-dlp".
|
|
27
|
+
*
|
|
28
|
+
* YouTube's JS challenges need a JavaScript runtime and yt-dlp enables only Deno by default,
|
|
29
|
+
* so it is handed the Bun running this process — which it supports — rather than a second
|
|
30
|
+
* runtime installed for one tool.
|
|
31
|
+
*
|
|
32
|
+
* 🔴 Mac-only. It spawns a process and writes files; nothing a Worker mounts may import it.
|
|
33
|
+
* It imports `node:*` and nothing else, which `leafSubpathsImportNothing.spec.ts` holds it to.
|
|
34
|
+
*/
|
|
35
|
+
import { spawn } from "node:child_process";
|
|
36
|
+
import { chmod, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import { dirname, join } from "node:path";
|
|
39
|
+
/** Where the standalone build is published. `latest` always names the newest release. */
|
|
40
|
+
const YTDLP_RELEASE_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos";
|
|
41
|
+
/** The markers this module asks yt-dlp to print, so its own chatter can never be mistaken for them. */
|
|
42
|
+
const PROGRESS_MARK = "ytprogress ";
|
|
43
|
+
const INFO_MARK = "ytinfo ";
|
|
44
|
+
/**
|
|
45
|
+
* A download that failed and was tried again. yt-dlp answers an intermittent `403` on the
|
|
46
|
+
* media request (measured 2026-09-30: one in three identical runs), and a retry is the fix.
|
|
47
|
+
*/
|
|
48
|
+
const ATTEMPTS = 3;
|
|
49
|
+
/** How often a fetcher may update yt-dlp by itself. A release that does not fix it will not
|
|
50
|
+
* start fixing it on the tenth try. */
|
|
51
|
+
const SELF_UPDATE_EVERY_MS = 60 * 60_000;
|
|
52
|
+
/** `$STATE/bin/yt-dlp` — one binary for every app on the Mac. `state` is the generation's state root. */
|
|
53
|
+
export function ytDlpPath(state) {
|
|
54
|
+
return join(state, "bin", "yt-dlp");
|
|
55
|
+
}
|
|
56
|
+
/** yt-dlp's cache (the player JS it has already solved), kept with the generation's state. */
|
|
57
|
+
export function ytDlpCacheDir(state) {
|
|
58
|
+
return join(state, "cache", "yt-dlp");
|
|
59
|
+
}
|
|
60
|
+
export class YtDlpError extends Error {
|
|
61
|
+
kind;
|
|
62
|
+
constructor(message, kind) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.kind = kind;
|
|
65
|
+
this.name = "YtDlpError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** A failure no retry and no update can change — the video's own fault, or the ceiling. */
|
|
69
|
+
export const isFinalYtDlpFailure = (kind) => kind === "unavailable" || kind === "too-big";
|
|
70
|
+
// ── Pure: reading what yt-dlp prints ───────────────────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* One progress line, as a fraction — or `null` for any other line.
|
|
73
|
+
*
|
|
74
|
+
* The template prints `downloaded total estimate`, and yt-dlp writes `NA` for a field it does
|
|
75
|
+
* not know. The exact total wins; the estimate is what a stream without a `content-length`
|
|
76
|
+
* has. With neither there is no honest fraction, so none is claimed.
|
|
77
|
+
*/
|
|
78
|
+
export function parseYtProgress(line) {
|
|
79
|
+
if (!line.startsWith(PROGRESS_MARK))
|
|
80
|
+
return null;
|
|
81
|
+
const [downloaded, total, estimate] = line.slice(PROGRESS_MARK.length).trim().split(/\s+/).map(Number);
|
|
82
|
+
const whole = Number.isFinite(total) && total > 0 ? total : estimate;
|
|
83
|
+
if (!Number.isFinite(downloaded) || !Number.isFinite(whole) || whole <= 0)
|
|
84
|
+
return null;
|
|
85
|
+
return Math.min(Math.max(downloaded / whole, 0), 1);
|
|
86
|
+
}
|
|
87
|
+
/** The info JSON printed after the file is in place, or `null` for any other line. */
|
|
88
|
+
export function parseYtInfo(line) {
|
|
89
|
+
if (!line.startsWith(INFO_MARK))
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const value = JSON.parse(line.slice(INFO_MARK.length));
|
|
93
|
+
return value && typeof value === "object" && typeof value.id === "string" ? value : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** yt-dlp's last `ERROR:` line — the one that says why — or the stderr's last line. */
|
|
100
|
+
function lastYtDlpError(stderr) {
|
|
101
|
+
const lines = stderr
|
|
102
|
+
.split("\n")
|
|
103
|
+
.map((l) => l.trim())
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
const errors = lines.filter((l) => l.startsWith("ERROR:"));
|
|
106
|
+
return (errors.at(-1) ?? lines.at(-1) ?? "").replace(/^ERROR:\s*/, "");
|
|
107
|
+
}
|
|
108
|
+
const UNAVAILABLE = /video unavailable|this video is unavailable|private video|has been removed|no longer available|account associated with this video has been terminated|not available in your country|blocked it in your country|members[- ]only|join this channel|confirm your age|age-restricted|copyright/i;
|
|
109
|
+
const BLOCKED = /confirm you.?re not a bot|sign in to confirm|too many requests|http error 429/i;
|
|
110
|
+
const OUTDATED = /unable to extract|nsig|n challenge|signature (extraction|function)|precondition check failed|http error 403|requested format is not available|please report this issue|js challenge|player .*(response|js)|unsupported url|jsc/i;
|
|
111
|
+
const TOO_BIG = /larger than max-filesize|max-filesize/i;
|
|
112
|
+
/** Sort a failure by what can be done about it — see {@link YtDlpFailure}. */
|
|
113
|
+
export function classifyYtDlpFailure(stderr) {
|
|
114
|
+
const line = lastYtDlpError(stderr);
|
|
115
|
+
if (TOO_BIG.test(line))
|
|
116
|
+
return "too-big";
|
|
117
|
+
if (UNAVAILABLE.test(line))
|
|
118
|
+
return "unavailable";
|
|
119
|
+
if (BLOCKED.test(line))
|
|
120
|
+
return "blocked";
|
|
121
|
+
if (OUTDATED.test(line))
|
|
122
|
+
return "outdated";
|
|
123
|
+
return "failed";
|
|
124
|
+
}
|
|
125
|
+
/** The sentence a downloads table shows. Every kind that has a remedy NAMES it. */
|
|
126
|
+
function describeYtDlpFailure(kind, detail, context) {
|
|
127
|
+
const fix = context.fixCommand;
|
|
128
|
+
switch (kind) {
|
|
129
|
+
case "missing":
|
|
130
|
+
return `yt-dlp is not installed on the Mac and installing it failed (${detail}). Run: ${fix}`;
|
|
131
|
+
case "outdated":
|
|
132
|
+
return (`yt-dlp could not read YouTube — it is out of date for a change YouTube made (${detail}). ` +
|
|
133
|
+
(context.updated
|
|
134
|
+
? `The agent already updated it and it still fails, so the fix is not released yet; try again in a day, or run: ${fix}`
|
|
135
|
+
: `Run: ${fix}`));
|
|
136
|
+
case "blocked":
|
|
137
|
+
return `YouTube is refusing this Mac's requests as a bot (${detail}). It usually lifts within hours — try again later. If it persists, update yt-dlp: ${fix}`;
|
|
138
|
+
case "unavailable":
|
|
139
|
+
return `YouTube will not play this video to anyone signed out (${detail}), so there is nothing to download.`;
|
|
140
|
+
case "too-big":
|
|
141
|
+
return `${context.profile.noun} is over this app's ${context.profile.maxFilesize}B ceiling for one download.`;
|
|
142
|
+
default:
|
|
143
|
+
return `yt-dlp failed: ${detail || "no reason given"}. If YouTube links keep failing, update it: ${fix}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The arguments for one download. Exported so a test pins the flags that matter. */
|
|
147
|
+
export function ytDlpArgs(videoId, outDir, profile, options = {}) {
|
|
148
|
+
return [
|
|
149
|
+
...(options.jsRuntime ? ["--js-runtimes", `bun:${options.jsRuntime}`] : []),
|
|
150
|
+
...(options.cacheDir ? ["--cache-dir", options.cacheDir] : []),
|
|
151
|
+
"--no-playlist",
|
|
152
|
+
"-f",
|
|
153
|
+
profile.format,
|
|
154
|
+
...(profile.extraArgs ?? []),
|
|
155
|
+
"--max-filesize",
|
|
156
|
+
profile.maxFilesize,
|
|
157
|
+
"--no-part",
|
|
158
|
+
"--no-mtime",
|
|
159
|
+
"--no-colors",
|
|
160
|
+
"--newline",
|
|
161
|
+
// `--print` implies `--quiet`, which would swallow the progress lines — hence `--progress`.
|
|
162
|
+
"--progress",
|
|
163
|
+
"--progress-template",
|
|
164
|
+
`download:${PROGRESS_MARK}%(progress.downloaded_bytes)s %(progress.total_bytes)s %(progress.total_bytes_estimate)s`,
|
|
165
|
+
"--print",
|
|
166
|
+
`after_move:${INFO_MARK}%()j`,
|
|
167
|
+
"-o",
|
|
168
|
+
join(outDir, "%(id)s.%(ext)s"),
|
|
169
|
+
"--",
|
|
170
|
+
`https://www.youtube.com/watch?v=${videoId}`,
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
const runLines = (binary, args, onLine) => new Promise((resolve, reject) => {
|
|
174
|
+
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
175
|
+
let buffer = "";
|
|
176
|
+
let stderr = "";
|
|
177
|
+
child.stdout.setEncoding("utf8");
|
|
178
|
+
child.stdout.on("data", (chunk) => {
|
|
179
|
+
buffer += chunk;
|
|
180
|
+
let at = buffer.indexOf("\n");
|
|
181
|
+
while (at >= 0) {
|
|
182
|
+
onLine(buffer.slice(0, at).replace(/\r$/, ""));
|
|
183
|
+
buffer = buffer.slice(at + 1);
|
|
184
|
+
at = buffer.indexOf("\n");
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
child.stderr.setEncoding("utf8");
|
|
188
|
+
child.stderr.on("data", (chunk) => {
|
|
189
|
+
// The tail is the part that says why; a runaway stderr must not grow without bound.
|
|
190
|
+
stderr = (stderr + chunk).slice(-16_384);
|
|
191
|
+
});
|
|
192
|
+
child.on("error", reject);
|
|
193
|
+
child.on("close", (code) => {
|
|
194
|
+
if (buffer)
|
|
195
|
+
onLine(buffer);
|
|
196
|
+
resolve({ code: code ?? 1, stderr });
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* Put the newest standalone build at `binary`. Written beside it and renamed into place, so a
|
|
201
|
+
* download that dies halfway never leaves a truncated executable where a fetcher looks.
|
|
202
|
+
*/
|
|
203
|
+
export async function installYtDlp(binary, fetchImpl = fetch) {
|
|
204
|
+
const res = await fetchImpl(YTDLP_RELEASE_URL, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
|
|
205
|
+
if (!res.ok)
|
|
206
|
+
throw new Error(`GitHub answered ${res.status} for ${YTDLP_RELEASE_URL}`);
|
|
207
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
208
|
+
// The standalone build is a Mach-O executable; anything small is an error page.
|
|
209
|
+
if (bytes.length < 1_000_000)
|
|
210
|
+
throw new Error(`the download was ${bytes.length} bytes, not a yt-dlp build`);
|
|
211
|
+
await mkdir(dirname(binary), { recursive: true });
|
|
212
|
+
const partial = `${binary}.partial`;
|
|
213
|
+
await writeFile(partial, bytes);
|
|
214
|
+
await chmod(partial, 0o755);
|
|
215
|
+
await rename(partial, binary);
|
|
216
|
+
}
|
|
217
|
+
/** `yt-dlp -U` — the standalone build updates itself in place. Resolves to what it printed. */
|
|
218
|
+
export async function updateYtDlp(binary, run = runLines) {
|
|
219
|
+
const lines = [];
|
|
220
|
+
const { code, stderr } = await run(binary, ["-U"], (line) => lines.push(line));
|
|
221
|
+
const said = [...lines, ...stderr.split("\n")].map((l) => l.trim()).filter(Boolean);
|
|
222
|
+
if (code !== 0)
|
|
223
|
+
throw new Error(said.at(-1) ?? `yt-dlp -U exited ${code}`);
|
|
224
|
+
return said.at(-1) ?? "";
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Install yt-dlp when it is missing, else update it — the body of each app's `bun run yt-dlp`.
|
|
228
|
+
* Resolves to the sentence to print.
|
|
229
|
+
*/
|
|
230
|
+
export async function installOrUpdateYtDlp(binary) {
|
|
231
|
+
if (!(await isFile(binary))) {
|
|
232
|
+
await installYtDlp(binary);
|
|
233
|
+
return `installed the latest release at ${binary}`;
|
|
234
|
+
}
|
|
235
|
+
return updateYtDlp(binary);
|
|
236
|
+
}
|
|
237
|
+
const isFile = async (path) => {
|
|
238
|
+
try {
|
|
239
|
+
return (await stat(path)).isFile();
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* The fetcher an app's download agent is handed. See the header for what it heals by itself
|
|
247
|
+
* and what it leaves as a sentence.
|
|
248
|
+
*/
|
|
249
|
+
export function createYtDlp(options) {
|
|
250
|
+
const run = options.run ?? runLines;
|
|
251
|
+
const install = options.install ?? ((binary) => installYtDlp(binary));
|
|
252
|
+
const update = options.update ?? ((binary) => updateYtDlp(binary, run));
|
|
253
|
+
const now = options.now ?? Date.now;
|
|
254
|
+
const label = options.label ?? "[yt-dlp]";
|
|
255
|
+
const jsRuntime = options.jsRuntime ?? (process.versions.bun ? process.execPath : undefined);
|
|
256
|
+
let lastUpdate = Number.NEGATIVE_INFINITY;
|
|
257
|
+
const describe = (kind, detail, updated) => describeYtDlpFailure(kind, detail, { updated, fixCommand: options.fixCommand, profile: options.profile });
|
|
258
|
+
const ensureInstalled = async () => {
|
|
259
|
+
if (await isFile(options.binary))
|
|
260
|
+
return;
|
|
261
|
+
options.log?.(`${label} yt-dlp: not at ${options.binary} — installing the latest release`);
|
|
262
|
+
try {
|
|
263
|
+
await install(options.binary);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
267
|
+
throw new YtDlpError(describe("missing", detail, false), "missing");
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
/** One yt-dlp run into a fresh directory. Throws a classified error on failure. */
|
|
271
|
+
const attempt = async (videoId, onProgress) => {
|
|
272
|
+
const dir = await mkdtemp(join(options.tmpRoot ?? tmpdir(), "ytdlp-"));
|
|
273
|
+
const dispose = () => rm(dir, { recursive: true, force: true });
|
|
274
|
+
try {
|
|
275
|
+
let info = null;
|
|
276
|
+
const { code, stderr } = await run(options.binary, ytDlpArgs(videoId, dir, options.profile, {
|
|
277
|
+
...(options.cacheDir ? { cacheDir: options.cacheDir } : {}),
|
|
278
|
+
...(jsRuntime ? { jsRuntime } : {}),
|
|
279
|
+
}), (line) => {
|
|
280
|
+
const fraction = parseYtProgress(line);
|
|
281
|
+
if (fraction !== null)
|
|
282
|
+
onProgress?.(fraction);
|
|
283
|
+
info = parseYtInfo(line) ?? info;
|
|
284
|
+
});
|
|
285
|
+
const found = info;
|
|
286
|
+
if (code !== 0 || !found?.filepath) {
|
|
287
|
+
const kind = code === 0 ? "failed" : classifyYtDlpFailure(stderr);
|
|
288
|
+
throw new YtDlpError(code === 0 ? "yt-dlp finished without saying where the file went" : lastYtDlpError(stderr), kind);
|
|
289
|
+
}
|
|
290
|
+
if (!(await isFile(found.filepath)))
|
|
291
|
+
throw new YtDlpError(`yt-dlp named ${found.filepath} and it is not there`, "failed");
|
|
292
|
+
const extension = (found.ext || found.filepath.split(".").at(-1) || "bin").toLowerCase();
|
|
293
|
+
return { path: found.filepath, extension, info: found, dir, dispose };
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
await dispose();
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
return async (videoId, onProgress) => {
|
|
301
|
+
await ensureInstalled();
|
|
302
|
+
let updated = false;
|
|
303
|
+
let last = null;
|
|
304
|
+
for (let tries = 1; tries <= ATTEMPTS + 1; tries++) {
|
|
305
|
+
try {
|
|
306
|
+
return await attempt(videoId, onProgress);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
last =
|
|
310
|
+
error instanceof YtDlpError ? error : new YtDlpError(error instanceof Error ? error.message : String(error), "failed");
|
|
311
|
+
if (isFinalYtDlpFailure(last.kind))
|
|
312
|
+
break;
|
|
313
|
+
if (tries < ATTEMPTS)
|
|
314
|
+
continue;
|
|
315
|
+
// Out of plain retries. One more, after an update, if it reads as out of date.
|
|
316
|
+
if (last.kind === "outdated" && !updated && now() - lastUpdate >= SELF_UPDATE_EVERY_MS) {
|
|
317
|
+
lastUpdate = now();
|
|
318
|
+
try {
|
|
319
|
+
const said = await update(options.binary);
|
|
320
|
+
updated = true;
|
|
321
|
+
options.log?.(`${label} yt-dlp: updated after "${last.message}" — ${said}`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
catch (updateError) {
|
|
325
|
+
options.log?.(`${label} yt-dlp: update failed — ${updateError instanceof Error ? updateError.message : String(updateError)}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const failure = last ?? new YtDlpError("no attempt ran", "failed");
|
|
332
|
+
throw new YtDlpError(describe(failure.kind, failure.message, updated), failure.kind);
|
|
333
|
+
};
|
|
334
|
+
}
|
package/docs/ctgr.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# ctgr + getOnly — the server invariants (don't reinvent the transport)
|
|
2
|
+
|
|
3
|
+
This package owns the GET-tunnel ("ctgr") and the auth guards (`src/server/ctgr.ts`,
|
|
4
|
+
`src/server/ctgrTunnel.ts`, `src/server/middleware/`). Use the primitives; do
|
|
5
|
+
not re-implement the wiring per app or per route.
|
|
6
|
+
|
|
7
|
+
- **Register routes with `buildRoutes`.** One `register(method, path, ...handlers)` call wires
|
|
8
|
+
both the real verb route **and** its `/ctgr<XX>` GET twin from a single call. Don't register
|
|
9
|
+
the twin by hand.
|
|
10
|
+
- **Mount the decode middleware on `CTGR_ROUTE_PATTERN`**, never on a literal `/ctgr*`. Hono's
|
|
11
|
+
router does **not** treat `/ctgr*` as a prefix wildcard (a bare-suffix `*` matches only one
|
|
12
|
+
following segment) — `CTGR_ROUTE_PATTERN` (`/:ctgrTwin{ctgr.+}`) is the correct portable
|
|
13
|
+
matcher. Reuse it everywhere a `/ctgr*` mount is meant.
|
|
14
|
+
- **`unGETify` only decodes** — it turns a `/ctgr<XX>` twin's query params back into the
|
|
15
|
+
original request and stashes the decoded request/method on the context. Protection lives
|
|
16
|
+
elsewhere by design:
|
|
17
|
+
- **getOnly** keys off the **RAW** request method (always `GET` on a twin) in the auth
|
|
18
|
+
middleware — that's what makes a tunneled write safe.
|
|
19
|
+
- **CSRF** double-submit fires on **every** `/ctgr*` path.
|
|
20
|
+
- **Binary/download routes MUST NOT go through the tunnel.** Register those as a plain
|
|
21
|
+
`app.get(path, handler)` so their bytes never ride the base64url codec and get mangled.
|
|
22
|
+
|
|
23
|
+
Moved here from `cursedbelt/docs/STANDARDS.md` §5 on 2026-10-05: it described this package's
|
|
24
|
+
server tier, which `cursedbelt` stopped shipping at the 2026-09-15 split.
|
package/docs/skipped-tests.md
CHANGED
|
@@ -7,13 +7,13 @@ carrying one goes red until it is removed. Inherited from `cursedbelt` at the
|
|
|
7
7
|
|
|
8
8
|
| file | waiting for |
|
|
9
9
|
| --- | --- |
|
|
10
|
-
| `src/server/search/pgSearchProvider.pg.test.ts` |
|
|
11
|
-
| `src/server/storage/transcode.integration.spec.ts` |
|
|
12
|
-
| `src/server/storage/binaryServerProcessing.integration.spec.ts` |
|
|
13
|
-
| `src/server/storage/chunked.integration.spec.ts` |
|
|
14
|
-
| `src/server/media-bun/image.spec.ts` | the optional `sharp` peer |
|
|
15
|
-
| `src/server/media-bun/video.spec.ts` | `ffmpeg` + `ffprobe` on `PATH` |
|
|
16
|
-
| `src/server/storage/imageThumbnail.spec.ts` | `ffmpeg` on `PATH` |
|
|
17
|
-
| `src/server/storage/videoProcessor.posterOnly.spec.ts` | `ffmpeg` on `PATH` |
|
|
18
|
-
| `src/server/storage/files/adapter.photo.display.spec.ts` | `ffmpeg` on `PATH` |
|
|
19
|
-
| `src/server/storage/fileUploadClient-chunked.integration.spec.ts` | a real `binary-server` checkout — set `BINARY_SERVER_DIR
|
|
10
|
+
| `src/server/search/pgSearchProvider.pg.test.ts` | `CC_PG_URL` naming a throwaway Postgres | `bun run test:pg` supplies one |
|
|
11
|
+
| `src/server/storage/transcode.integration.spec.ts` | a real `binary-server` checkout — set `BINARY_SERVER_DIR`. It starts binary-server's processing worker beside the store, because only the worker runs a job |
|
|
12
|
+
| `src/server/storage/binaryServerProcessing.integration.spec.ts` | a real `binary-server` checkout (`BINARY_SERVER_DIR`) and `ffmpeg` on `PATH`. It starts the processing worker too |
|
|
13
|
+
| `src/server/storage/chunked.integration.spec.ts` | a real `binary-server` checkout — set `BINARY_SERVER_DIR` |
|
|
14
|
+
| `src/server/media-bun/image.spec.ts` | the optional `sharp` peer |
|
|
15
|
+
| `src/server/media-bun/video.spec.ts` | `ffmpeg` + `ffprobe` on `PATH` |
|
|
16
|
+
| `src/server/storage/imageThumbnail.spec.ts` | `ffmpeg` on `PATH` |
|
|
17
|
+
| `src/server/storage/videoProcessor.posterOnly.spec.ts` | `ffmpeg` on `PATH` |
|
|
18
|
+
| `src/server/storage/files/adapter.photo.display.spec.ts` | `ffmpeg` on `PATH` |
|
|
19
|
+
| `src/server/storage/fileUploadClient-chunked.integration.spec.ts` | a real `binary-server` checkout — set `BINARY_SERVER_DIR` to its path (`apps/binary-server` in this generation). No default by design: a published package must not go looking in one machine's directory layout. Moved here from `cursedbelt` at the 2026-09-15 split, with the storage internals it drives. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.43.1",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -343,6 +343,18 @@
|
|
|
343
343
|
"source": "./src/server/sync/index.ts",
|
|
344
344
|
"import": "./dist/server/sync/index.js"
|
|
345
345
|
},
|
|
346
|
+
"./youtube": {
|
|
347
|
+
"types": "./dist/server/youtube/youtubeApi.d.ts",
|
|
348
|
+
"bun": "./src/server/youtube/youtubeApi.ts",
|
|
349
|
+
"source": "./src/server/youtube/youtubeApi.ts",
|
|
350
|
+
"import": "./dist/server/youtube/youtubeApi.js"
|
|
351
|
+
},
|
|
352
|
+
"./youtube/ytdlp": {
|
|
353
|
+
"types": "./dist/server/youtube/ytdlp.d.ts",
|
|
354
|
+
"bun": "./src/server/youtube/ytdlp.ts",
|
|
355
|
+
"source": "./src/server/youtube/ytdlp.ts",
|
|
356
|
+
"import": "./dist/server/youtube/ytdlp.js"
|
|
357
|
+
},
|
|
346
358
|
"./ws": {
|
|
347
359
|
"types": "./dist/server/ws/index.d.ts",
|
|
348
360
|
"bun": "./src/server/ws/index.ts",
|
|
@@ -353,7 +365,7 @@
|
|
|
353
365
|
"dependencies": {
|
|
354
366
|
"argon2id": "1.0.1",
|
|
355
367
|
"cursedbelt-core": "^2.1.1",
|
|
356
|
-
"cursedops": ">=0.8.1 <0.
|
|
368
|
+
"cursedops": ">=0.8.1 <1.0.0",
|
|
357
369
|
"cwip": "^4.6.0",
|
|
358
370
|
"jose": "^6.2.3"
|
|
359
371
|
},
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import pkg from '../package.json';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `cursedops` is a dependency here, so an app that takes a cursedops this range does not admit
|
|
6
|
+
* installs TWO copies — the app's at the top and a nested one under `cursedbelt-server`. The
|
|
7
|
+
* range used to stop at the next 0.x minor (`<0.10.0`, `<0.12.0`, `<0.13.0`, `<0.14.0`), and every
|
|
8
|
+
* cursedops minor needed a hand-widening here before the ship could bump apps onto it with one
|
|
9
|
+
* copy (4.30.1, 4.40.3, 4.40.4, and again for cursedops 0.14.0 — forge task 2832). All this
|
|
10
|
+
* package takes from it is `forgeState` from `cursedops/roots`, and `tools/publish-libs.sh`'s
|
|
11
|
+
* consumer preflight runs every app on a new cursedops before it ships, so the cap bought nothing
|
|
12
|
+
* but the chore. It is gone; this keeps it gone for the whole 0.x line.
|
|
13
|
+
*/
|
|
14
|
+
describe('cursedops range', () => {
|
|
15
|
+
const range = pkg.dependencies.cursedops;
|
|
16
|
+
|
|
17
|
+
it('admits every later cursedops 0.x minor, so an app bumping cursedops keeps one copy', () => {
|
|
18
|
+
for (const version of ['0.14.0', '0.15.0', '0.20.3', '0.99.0']) {
|
|
19
|
+
expect({ version, admitted: Bun.semver.satisfies(version, range) }).toEqual({ version, admitted: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('still refuses what predates forgeState', () => {
|
|
24
|
+
expect(Bun.semver.satisfies('0.8.0', range)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -225,6 +225,24 @@ const LEAVES = [
|
|
|
225
225
|
evaluates: 'argon2Cost',
|
|
226
226
|
siblings: ['src/server/satellite/door.ts'],
|
|
227
227
|
},
|
|
228
|
+
{
|
|
229
|
+
subpath: './youtube',
|
|
230
|
+
/**
|
|
231
|
+
* The YouTube Data API reader (4.43.0, forge task 2349) — out of Music so Collections lists
|
|
232
|
+
* the same playlists. Collections calls it from a Worker, so it may reach nothing but
|
|
233
|
+
* `fetch`; its one import is `import type` of `./google-oauth`'s `FetchLike`, which is erased.
|
|
234
|
+
*/
|
|
235
|
+
evaluates: 'createYouTubeApi',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
subpath: './youtube/ytdlp',
|
|
239
|
+
/**
|
|
240
|
+
* The yt-dlp runner (4.43.0, forge task 2349) shared by Music's audio and Collections' video
|
|
241
|
+
* agents. Mac-only, and its promise is `node:*` and nothing else — no `cursedops`, so the
|
|
242
|
+
* caller says where the binary is.
|
|
243
|
+
*/
|
|
244
|
+
evaluates: 'createYtDlp',
|
|
245
|
+
},
|
|
228
246
|
] as const;
|
|
229
247
|
|
|
230
248
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { Subprocess } from 'bun';
|
|
2
3
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
4
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
@@ -19,7 +20,11 @@ const FFMPEG_PRESENT = Bun.which('ffmpeg') !== null;
|
|
|
19
20
|
|
|
20
21
|
// SKIPPED until a real binary-server checkout is present: point BINARY_SERVER_DIR at one.
|
|
21
22
|
// There is deliberately no default — a published package must not go looking in one machine's
|
|
22
|
-
// directory layout
|
|
23
|
+
// directory layout. In this generation that checkout is `$FORGE/apps/binary-server`.
|
|
24
|
+
// BS_ALLOW_NEW_STORE=1: the spawned server refuses to invent a store it was not given, and this
|
|
25
|
+
// throwaway one is exactly a store it should create.
|
|
26
|
+
// BS_SECRETS_FILE=/dev/null: otherwise it folds in this machine's binary-server.env — the real R2
|
|
27
|
+
// keys and passcode — and a throwaway store has no business holding them.
|
|
23
28
|
// ffmpeg must be on PATH too: the proof is that the REMOTE side runs real ffmpeg, not that the client posted a job.
|
|
24
29
|
describe.skipIf(!BS_PRESENT || !FFMPEG_PRESENT)(
|
|
25
30
|
'binaryServerProcessing → real binary-server (integration)',
|
|
@@ -27,6 +32,7 @@ describe.skipIf(!BS_PRESENT || !FFMPEG_PRESENT)(
|
|
|
27
32
|
let port: number;
|
|
28
33
|
let storeDir: string;
|
|
29
34
|
let server: TestServer | null = null;
|
|
35
|
+
let worker: Subprocess | null = null;
|
|
30
36
|
let tmpData: string;
|
|
31
37
|
const INTERNAL_SECRET = 'test-processing-secret';
|
|
32
38
|
const APP = 'ptest';
|
|
@@ -36,21 +42,33 @@ describe.skipIf(!BS_PRESENT || !FFMPEG_PRESENT)(
|
|
|
36
42
|
storeDir = path.join(tmpData, 'binaries');
|
|
37
43
|
mkdirSync(storeDir, { recursive: true });
|
|
38
44
|
port = await pickFreePort();
|
|
45
|
+
const env = {
|
|
46
|
+
PORT: String(port),
|
|
47
|
+
HOST: '127.0.0.1',
|
|
48
|
+
STORAGE_ROOT: storeDir,
|
|
49
|
+
DB_PATH: path.join(tmpData, 'test.sqlite'),
|
|
50
|
+
BS_ALLOW_NEW_STORE: '1',
|
|
51
|
+
BS_SECRETS_FILE: '/dev/null',
|
|
52
|
+
INTERNAL_SECRET,
|
|
53
|
+
NODE_ENV: 'test',
|
|
54
|
+
};
|
|
39
55
|
server = await startTestServer({
|
|
40
56
|
cwd: BS_DIR,
|
|
41
57
|
cmd: ['bun', path.join(BS_DIR, 'src', 'index.ts')],
|
|
42
58
|
port,
|
|
43
|
-
env
|
|
44
|
-
PORT: String(port),
|
|
45
|
-
HOST: '127.0.0.1',
|
|
46
|
-
STORAGE_ROOT: storeDir,
|
|
47
|
-
DB_PATH: path.join(tmpData, 'test.sqlite'),
|
|
48
|
-
INTERNAL_SECRET,
|
|
49
|
-
NODE_ENV: 'test',
|
|
50
|
-
},
|
|
59
|
+
env,
|
|
51
60
|
healthPath: '/healthz',
|
|
52
61
|
timeoutMs: 90_000,
|
|
53
62
|
});
|
|
63
|
+
// The store only ENQUEUES a job; binary-server's processing worker (its own process since
|
|
64
|
+
// bs task 2694) leases and runs it, and writes each output back through the store's API.
|
|
65
|
+
// Without one, every job stays `pending` and the client times out.
|
|
66
|
+
worker = Bun.spawn(['bun', path.join(BS_DIR, 'src', 'processingWorker.ts')], {
|
|
67
|
+
cwd: BS_DIR,
|
|
68
|
+
env: { ...process.env, ...env },
|
|
69
|
+
stdout: 'inherit',
|
|
70
|
+
stderr: 'inherit',
|
|
71
|
+
});
|
|
54
72
|
|
|
55
73
|
// Register the tenant, the way `register-app` does for a real one. This is
|
|
56
74
|
// not harness ceremony: since binary-server started INDEXING every transcode
|
|
@@ -71,7 +89,7 @@ describe.skipIf(!BS_PRESENT || !FFMPEG_PRESENT)(
|
|
|
71
89
|
],
|
|
72
90
|
{
|
|
73
91
|
cwd: BS_DIR,
|
|
74
|
-
env: { ...process.env, STORAGE_ROOT: storeDir, DB_PATH: path.join(tmpData, 'test.sqlite') },
|
|
92
|
+
env: { ...process.env, STORAGE_ROOT: storeDir, DB_PATH: path.join(tmpData, 'test.sqlite'), BS_ALLOW_NEW_STORE: '1', BS_SECRETS_FILE: '/dev/null' },
|
|
75
93
|
stdout: 'inherit',
|
|
76
94
|
stderr: 'inherit',
|
|
77
95
|
},
|
|
@@ -80,6 +98,8 @@ describe.skipIf(!BS_PRESENT || !FFMPEG_PRESENT)(
|
|
|
80
98
|
}, 90_000);
|
|
81
99
|
|
|
82
100
|
afterAll(async () => {
|
|
101
|
+
worker?.kill();
|
|
102
|
+
await worker?.exited;
|
|
83
103
|
await server?.stop();
|
|
84
104
|
await rm(tmpData, { recursive: true, force: true }).catch(() => void 0);
|
|
85
105
|
});
|
|
@@ -97,7 +117,7 @@ describe.skipIf(!BS_PRESENT || !FFMPEG_PRESENT)(
|
|
|
97
117
|
`;
|
|
98
118
|
const proc = Bun.spawn(['bun', '-e', helper, `${APP}/${key}`, localFile], {
|
|
99
119
|
cwd: BS_DIR,
|
|
100
|
-
env: { ...process.env, STORAGE_ROOT: storeDir, DB_PATH: path.join(tmpData, 'test.sqlite') },
|
|
120
|
+
env: { ...process.env, STORAGE_ROOT: storeDir, DB_PATH: path.join(tmpData, 'test.sqlite'), BS_ALLOW_NEW_STORE: '1', BS_SECRETS_FILE: '/dev/null' },
|
|
101
121
|
stdout: 'inherit',
|
|
102
122
|
stderr: 'inherit',
|
|
103
123
|
});
|
|
@@ -40,7 +40,11 @@ const pem = (der: ArrayBuffer, label: string) =>
|
|
|
40
40
|
|
|
41
41
|
// SKIPPED until a real binary-server checkout is present: point BINARY_SERVER_DIR at one.
|
|
42
42
|
// There is deliberately no default — a published package must not go looking in one machine's
|
|
43
|
-
// directory layout
|
|
43
|
+
// directory layout. In this generation that checkout is `$FORGE/apps/binary-server`.
|
|
44
|
+
// BS_ALLOW_NEW_STORE=1: the spawned server refuses to invent a store it was not given, and this
|
|
45
|
+
// throwaway one is exactly a store it should create.
|
|
46
|
+
// BS_SECRETS_FILE=/dev/null: otherwise it folds in this machine's binary-server.env — the real R2
|
|
47
|
+
// keys and passcode — and a throwaway store has no business holding them.
|
|
44
48
|
describe.skipIf(!BS_PRESENT)('chunked upload → real binary-server (integration)', () => {
|
|
45
49
|
let server: TestServer | null = null;
|
|
46
50
|
let tmp = '';
|
|
@@ -83,7 +87,7 @@ describe.skipIf(!BS_PRESENT)('chunked upload → real binary-server (integration
|
|
|
83
87
|
PORT: String(port),
|
|
84
88
|
HOST: '127.0.0.1',
|
|
85
89
|
STORAGE_ROOT: path.join(tmp, 'binaries'),
|
|
86
|
-
DB_PATH: path.join(tmp, 'bs.sqlite'),
|
|
90
|
+
DB_PATH: path.join(tmp, 'bs.sqlite'), BS_ALLOW_NEW_STORE: '1', BS_SECRETS_FILE: '/dev/null',
|
|
87
91
|
FILE_TOKEN_PUBLIC_KEY: spki,
|
|
88
92
|
FILE_TOKEN_ISSUER: ISSUER,
|
|
89
93
|
CA_APP_ID: APP_ID,
|
|
@@ -50,7 +50,11 @@ const pem = (der: ArrayBuffer, label: string) =>
|
|
|
50
50
|
|
|
51
51
|
// SKIPPED until a real binary-server checkout is present: point BINARY_SERVER_DIR at one.
|
|
52
52
|
// There is deliberately no default — a published package must not go looking in one machine's
|
|
53
|
-
// directory layout
|
|
53
|
+
// directory layout. In this generation that checkout is `$FORGE/apps/binary-server`.
|
|
54
|
+
// BS_ALLOW_NEW_STORE=1: the spawned server refuses to invent a store it was not given, and this
|
|
55
|
+
// throwaway one is exactly a store it should create.
|
|
56
|
+
// BS_SECRETS_FILE=/dev/null: otherwise it folds in this machine's binary-server.env — the real R2
|
|
57
|
+
// keys and passcode — and a throwaway store has no business holding them.
|
|
54
58
|
describe.skipIf(!BS_PRESENT)('fileUploadClient chunked path → real binary-server (integration)', () => {
|
|
55
59
|
let server: TestServer | null = null;
|
|
56
60
|
let tmp = '';
|
|
@@ -90,7 +94,7 @@ describe.skipIf(!BS_PRESENT)('fileUploadClient chunked path → real binary-serv
|
|
|
90
94
|
PORT: String(port),
|
|
91
95
|
HOST: '127.0.0.1',
|
|
92
96
|
STORAGE_ROOT: path.join(tmp, 'binaries'),
|
|
93
|
-
DB_PATH: path.join(tmp, 'bs.sqlite'),
|
|
97
|
+
DB_PATH: path.join(tmp, 'bs.sqlite'), BS_ALLOW_NEW_STORE: '1', BS_SECRETS_FILE: '/dev/null',
|
|
94
98
|
FILE_TOKEN_PUBLIC_KEY: spki,
|
|
95
99
|
FILE_TOKEN_ISSUER: ISSUER,
|
|
96
100
|
CA_APP_ID: APP_ID,
|