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}`); }