From 42adfda390c4204431161d771b5ad4535fa987b8 Mon Sep 17 00:00:00 2001 From: "Jesse.Markowitz" Date: Sat, 22 Aug 2026 22:04:23 -0400 Subject: [PATCH] =?UTF-8?q?v0.6.3=20=E2=80=94=20the=20deploy=20script=20fo?= =?UTF-8?q?llows=20the=20host=20to=20FileBrowser=20Quantum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy script only. No rules change, no game change, and the built site is byte-for-byte what 0.6.2 produced — this repairs the path that publishes it. Found trying to publish the 0.4.9f playtest build: every deploy died with "login failed: 404 404 page not found". The File Browser host has been upgraded to FileBrowser Quantum, a fork whose API differs from the v2.63 one deploy-web.ts was written against. Three things moved at once, each fatal on its own: auth is a session COOKIE rather than a JWT sent back as X-Auth, so a deploy that ignored it would authenticate and then be rejected by every upload; the password is an X-Password header, URL-encoded, rather than a JSON body field; and the path is a query parameter, with every resource call also having to name a `source` — Quantum can serve several named stores and refuses any call that does not say which, a concept the v2.63 API did not have. Rewritten against the running instance's own bundle rather than guessed, the same discipline the v2.63 version was written with. Two things worth knowing next time: the bundle is gzip-compressed, so it needs gunzip before it can be grepped; and an endpoint that exists answers a bad password with 401 while a missing one answers 404, which is how each path was confirmed against the live host without holding a password. The source is discovered from GET /api/settings/sources — one configured source is used silently, several makes the script stop and list them rather than deploy into the wrong store. FB_SOURCE overrides it, FB_OTP carries a two-factor code. Verified by deploying with it rather than by reading: 0.4.9f went up this way. This is that same file byte-identical, brought across to the main line — both branches carried the same broken script, so deploying 0.6.x would have failed identically. Also removes dist-test/, an untracked hand-made copy of a v0.6.2 dist/ build that no script or test references. build-web.ts hardcodes dist and wipes it on every run, so nothing in the repo could have produced that directory or would ever read it. .gitignore is deliberately unchanged: the answer for a directory that should not exist is to delete it, not to hide it. 715 tests pass, tsc clean, site builds. --- CHANGELOG.md | 43 ++++++++++++++ package.json | 2 +- scripts/deploy-web.ts | 131 +++++++++++++++++++++++++++++++----------- 3 files changed, 140 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b83bf6..2aa4a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,49 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.6.3 — 2026-08-23 + +The deploy script only. No rules change, no game change, and the built site is byte-for-byte what +0.6.2 produced — this repairs the path that publishes it. + +### The host moved to FileBrowser Quantum + +Found trying to publish the 0.4.9f playtest build: every deploy died with +`login failed: 404 404 page not found`. The File Browser instance has been upgraded to +**FileBrowser Quantum**, a fork whose API differs from the v2.63 one `deploy-web.ts` was written +against. Three things moved at once, each fatal on its own: + +1. **Auth is a session COOKIE**, not a JWT returned in the response body and sent back as `X-Auth:`. + A deploy that ignored the cookie would authenticate and then be rejected by every upload. +2. **The password is a header** — `X-Password`, URL-encoded — not a JSON body field. +3. **The path is a query parameter** (`?path=`), and every resource call must also name a + **`source`**: Quantum can serve several named stores and refuses any call that does not say which + ("no source provided"). The v2.63 API had no such concept at all. + +Rewritten against the running instance's own bundle rather than guessed — the same discipline the +v2.63 version was written with, and worth repeating: the bundle at `/public/static/assets/index-*.js` +is **gzip-compressed**, so it has to go through `gunzip` before it can be grepped. Each path was then +confirmed against the live host by response code, which is the cheap way to tell a moved endpoint +from a bad password without holding a password: **an endpoint that exists answers 401, one that does +not answers 404.** + +The source is discovered from `GET /api/settings/sources` — one configured source is used silently, +and several makes the script stop and list them rather than deploy the site into the wrong store. +`FB_SOURCE` overrides it; `FB_OTP` carries a two-factor code. + +**Verified by deploying with it**, not by reading: 0.4.9f went up this way. This commit is the same +file, byte-identical, brought across to the main line — both branches had carried the same broken +script, so deploying 0.6.x would have failed in exactly the same way. + +### Housekeeping + +`dist-test/` removed — an untracked hand-made copy of a v0.6.2 `dist/` build, referenced by no +script and no test. `build-web.ts` hardcodes `dist` and wipes it on every run, so nothing in the repo +could have produced that directory or would ever read it. `.gitignore` is deliberately unchanged: +the answer for a directory that should not exist is to delete it, not to hide it. + +--- + ## 0.6.2 — 2026-08-22 Three more from the v0.4.9e gameplay-testing round, now filed as Gitea issues: **#4** extras did not diff --git a/package.json b/package.json index 321b8de..f370b14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.6.2", + "version": "0.6.3", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/scripts/deploy-web.ts b/scripts/deploy-web.ts index 12477d3..a4c1d71 100644 --- a/scripts/deploy-web.ts +++ b/scripts/deploy-web.ts @@ -1,11 +1,29 @@ /** - * Build the solitaire site and push it to a File Browser instance. + * Build the solitaire site and push it to a FileBrowser instance. * - * Written against File Browser v2.63's REST API, read from its own bundle rather than guessed: + * REWRITTEN FOR **FileBrowser Quantum** (2026-08-23). The host was upgraded from File Browser v2.63 + * to the Quantum fork, whose API is different in three ways at once, and every deploy failed with + * `login failed: 404 404 page not found` — the old `/api/login` simply is not there any more. * - * POST /api/login {username, password, recaptcha} -> JWT as plain text - * POST /api/resources// X-Auth: -> create a directory - * POST /api/resources/?override=true X-Auth: , body = bytes -> upload + * Read from the running instance's own bundle rather than guessed, the same way the v2.63 version + * was (`/public/static/assets/index-*.js`, gzipped — pipe it through `gunzip` before grepping), and + * each path confirmed against the live host by the response code: an endpoint that exists answers a + * bad password with **401**, one that does not answers **404**. + * + * POST /api/auth/login?username=&recaptcha= + * headers X-Password: , X-Secret: -> sets a session COOKIE + * GET /api/settings/sources -> the named sources + * POST /api/resources?path=

&source=&isDir=true -> create a directory + * POST /api/resources?path=

&source=&override=true body=bytes -> upload + * + * THREE THINGS MOVED, and each would break on its own: + * 1. AUTH IS A COOKIE, not an `X-Auth: ` header. Login returns no usable token in its body; + * the session arrives in `Set-Cookie` and every later request has to carry it back. + * 2. THE PASSWORD IS A HEADER, `X-Password`, URL-encoded — not a JSON body field. + * 3. THE PATH IS A QUERY PARAMETER, `?path=`, not part of the URL, and every resource call also + * needs a **`source`** naming which configured store to write to. Quantum throws "no source + * provided" without it. `FB_SOURCE` names it; left unset, the sole configured source is used, + * and if there is more than one this stops and lists them rather than guessing. * * File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site. * So the job here is simply to land the built files in the right folder, intact. @@ -18,6 +36,8 @@ * Optional: * FB_URL default https://phoenix.local:58157 * FB_DEST default websites/stationmaster — the folder Start9 Pages serves from + * FB_SOURCE which configured source to write to; discovered automatically when there is one + * FB_OTP the one-time code, if the account has two-factor enabled * FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host) * SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at * --dry-run list what would be sent, contact nothing @@ -44,6 +64,8 @@ const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replac const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`; const USER = process.env['FB_USER'] ?? ''; const PASS = process.env['FB_PASS'] ?? ''; +const OTP = process.env['FB_OTP'] ?? ''; +const SOURCE_ENV = process.env['FB_SOURCE'] ?? ''; const DRY = process.argv.includes('--dry-run'); /** @@ -75,37 +97,80 @@ const CONTENT_TYPES: Record = { '.txt': 'text/plain', }; +/** + * Log in and return the session cookie every later request must carry. + * + * The password goes in a HEADER and URL-encoded, which is Quantum's own client does + * (`X-Password: encodeURIComponent(password)`). The body carries nothing useful on success — the + * session is in `Set-Cookie`, so a deploy that ignored the cookie would authenticate and then be + * rejected by every upload. + */ async function login(): Promise { - const res = await fetch(`${URL_BASE}/api/login`, { + const url = `${URL_BASE}/api/auth/login?username=${encodeURIComponent(USER)}&recaptcha=`; + const res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }), + headers: { 'X-Password': encodeURIComponent(PASS), 'X-Secret': OTP }, }); const body = await res.text(); - if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`); - if (!body.trim()) throw new Error('login returned an empty token'); - return body.trim(); -} - -async function makeDir(jwt: string, path: string): Promise { - // Trailing slash is what marks a directory in this API. A 409 means it already exists, which is - // the normal case on every deploy after the first. - const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, { - method: 'POST', - headers: { 'X-Auth': jwt }, - }); - if (!res.ok && res.status !== 409) { - throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`); + if (!res.ok) { + // 401 here is a wrong username/password; 404 would mean this build has moved the API again. + throw new Error(`login failed: ${res.status} ${body || res.statusText}`); } + const cookies = res.headers.getSetCookie(); + if (cookies.length === 0) throw new Error('login succeeded but set no session cookie'); + return cookies.map((c) => c.split(';')[0]).join('; '); } -async function upload(jwt: string, localPath: string, remotePath: string): Promise { +/** + * WHICH STORE TO WRITE TO. Quantum can serve several named sources and refuses any resource call + * that does not name one ("no source provided"), which is the parameter the v2.63 API had no + * concept of. One configured source is the normal case and is used without asking; more than one is + * ambiguous, and guessing would silently deploy the site into the wrong store. + */ +async function resolveSource(cookie: string): Promise { + if (SOURCE_ENV) return SOURCE_ENV; + const res = await fetch(`${URL_BASE}/api/settings/sources`, { headers: { cookie } }); + if (!res.ok) throw new Error(`could not list sources: ${res.status} ${await res.text()}`); + const names = Object.keys((await res.json()) ?? {}); + if (names.length === 1) return names[0]!; + if (names.length === 0) throw new Error('the server reports no sources at all'); + throw new Error(`several sources configured (${names.join(', ')}) — pick one with FB_SOURCE=`); +} + +function resourceUrl(source: string, path: string, extra: Record): string { + const params = new URLSearchParams({ path, source, ...extra }); + return `${URL_BASE}/api/resources?${params}`; +} + +async function makeDir(cookie: string, source: string, path: string): Promise { + const res = await fetch(resourceUrl(source, path, { isDir: 'true' }), { + method: 'POST', + headers: { cookie }, + }); + if (res.ok) return; + /** + * "Already there" is the normal case on every deploy after the first, and Quantum is not + * consistent about which code it reports it with. So the two failures worth stopping for are + * named — a rejected session, and a server that broke — and every other 4xx is treated as the + * directory already existing. A directory that genuinely is not there fails loudly at the upload + * a moment later, which is a better place to find out than a guess here. + */ + const fatal = res.status === 401 || res.status === 403 || res.status >= 500; + if (fatal) throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`); +} + +async function upload( + cookie: string, + source: string, + localPath: string, + remotePath: string, +): Promise { const bytes = readFileSync(localPath); const ext = remotePath.slice(remotePath.lastIndexOf('.')); - const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, { + const res = await fetch(resourceUrl(source, remotePath, { override: 'true' }), { method: 'POST', headers: { - 'X-Auth': jwt, + cookie, 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream', 'Content-Length': String(bytes.byteLength), }, @@ -114,11 +179,6 @@ async function upload(jwt: string, localPath: string, remotePath: string): Promi if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`); } -/** Encode each segment but keep the separators, so a path stays a path. */ -function encodePath(p: string): string { - return p.split('/').map(encodeURIComponent).join('/'); -} - // --------------------------------------------------------------------------- console.log('building…'); @@ -147,15 +207,16 @@ if (DRY) { ); } - const jwt = await login(); - console.log('logged in'); + const cookie = await login(); + const source = await resolveSource(cookie); + console.log(`logged in — writing to source "${source}"`); - await makeDir(jwt, DEST); - for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`); + await makeDir(cookie, source, DEST); + for (const d of dirs) await makeDir(cookie, source, `${DEST}/${d}`); let done = 0; for (const f of files) { - await upload(jwt, join(dist, f), `${DEST}/${f}`); + await upload(cookie, source, join(dist, f), `${DEST}/${f}`); done++; console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`); }