diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 7a77469..25fe9cd 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -37,10 +37,46 @@ jobs: run: npm run coverage - name: Build + env: + SALVO_BUILD_ID: ${{ github.sha }} run: npm run build + - name: Verify Telegram Mini App artifacts + env: + SALVO_BUILD_ID: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + test -f dist/telegram/index.html + sdk_occurrences="$( + awk ' + { + remainder = $0 + token = "telegram-web-app.js" + while ((position = index(remainder, token)) > 0) { + count += 1 + remainder = substr(remainder, position + length(token)) + } + } + END { print count + 0 } + ' dist/telegram/index.html + )" + test "$sdk_occurrences" -eq 1 + ! grep -Fq 'telegram-web-app.js' dist/index.html + web_app="$(grep -oE 'app\.[a-f0-9]{10}\.js' dist/index.html)" + telegram_app="$(grep -oE 'app\.[a-f0-9]{10}\.js' dist/telegram/index.html)" + test "$web_app" = "$telegram_app" + test -f "dist/$web_app" + web_styles="$(grep -oE 'styles\.[a-f0-9]{10}\.css' dist/index.html)" + telegram_styles="$(grep -oE 'styles\.[a-f0-9]{10}\.css' dist/telegram/index.html)" + test "$web_styles" = "$telegram_styles" + test -f "dist/$web_styles" + grep -Fq "buildId: \"$SALVO_BUILD_ID\"" dist/index.html + grep -Fq "buildId: \"$SALVO_BUILD_ID\"" dist/telegram/index.html + android: runs-on: ubuntu-latest + needs: web steps: - name: Checkout uses: actions/checkout@v7 @@ -62,6 +98,8 @@ jobs: run: npm ci - name: Sync native projects + env: + SALVO_BUILD_ID: ${{ github.sha }} run: npm run mobile:sync - name: Test, lint, and assemble Android @@ -87,6 +125,7 @@ jobs: ios: runs-on: macos-26 + needs: web steps: - name: Checkout uses: actions/checkout@v7 @@ -104,6 +143,8 @@ jobs: run: npm ci - name: Sync native projects + env: + SALVO_BUILD_ID: ${{ github.sha }} run: npm run mobile:sync - name: Build iOS Simulator diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e2576b3..653023b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -38,8 +38,43 @@ jobs: run: npm run coverage - name: Build + env: + SALVO_BUILD_ID: ${{ github.sha }} run: npm run build + - name: Verify Telegram Mini App artifacts + env: + SALVO_BUILD_ID: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + test -f dist/telegram/index.html + sdk_occurrences="$( + awk ' + { + remainder = $0 + token = "telegram-web-app.js" + while ((position = index(remainder, token)) > 0) { + count += 1 + remainder = substr(remainder, position + length(token)) + } + } + END { print count + 0 } + ' dist/telegram/index.html + )" + test "$sdk_occurrences" -eq 1 + ! grep -Fq 'telegram-web-app.js' dist/index.html + web_app="$(grep -oE 'app\.[a-f0-9]{10}\.js' dist/index.html)" + telegram_app="$(grep -oE 'app\.[a-f0-9]{10}\.js' dist/telegram/index.html)" + test "$web_app" = "$telegram_app" + test -f "dist/$web_app" + web_styles="$(grep -oE 'styles\.[a-f0-9]{10}\.css' dist/index.html)" + telegram_styles="$(grep -oE 'styles\.[a-f0-9]{10}\.css' dist/telegram/index.html)" + test "$web_styles" = "$telegram_styles" + test -f "dist/$web_styles" + grep -Fq "buildId: \"$SALVO_BUILD_ID\"" dist/index.html + grep -Fq "buildId: \"$SALVO_BUILD_ID\"" dist/telegram/index.html + - name: Configure Pages uses: actions/configure-pages@v6 diff --git a/README.md b/README.md index 1d43bf8..f383196 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Salvo is a browser Battleship game for GitHub Pages with three localizations, sa Live build: https://agent-axiom.github.io/agents-salvo/ +Telegram Mini App: https://agent-axiom.github.io/agents-salvo/telegram/ + ![Salvo paper board artwork](src/assets/salvo-board-action.png) ## Features @@ -34,6 +36,16 @@ npm start After `npm start`, open `http://localhost:5173`. +## Telegram Mini App + +In [@BotFather](https://t.me/BotFather), select the Salvo bot, open the Main Mini App setup, and set its URL to `https://agent-axiom.github.io/agents-salvo/telegram/`. + +When opened inside Telegram, the Mini App automatically sends Telegram's signed `initData` to the Cloudflare Worker. The Worker verifies the signature and freshness before creating the existing Salvo session, so players do not complete a separate login flow. + +The browser, Telegram Mini App, iOS app, and Android app use one source tree and one `npm run build`. That build emits the regular and Telegram HTML shells with one shared hashed JavaScript bundle and stylesheet; only the Telegram shell loads the Telegram SDK. + +Pages and the Mini App update immediately when the Pages artifact is published. Native apps do not load Pages at startup: each APK or iOS app packages the build from a selected commit and changes only when that commit is packaged and released. + ## iOS And Android Development The mobile apps bundle the same `dist/` build as GitHub Pages; they do not load the public site at startup. Agent, training, and same-device battles therefore work offline. Online rooms, Telegram login, profiles, and leaderboards require access to the Cloudflare Worker. @@ -90,7 +102,7 @@ API publishing is available after RuStore has one active version. Run `Check RuS 2. In Settings -> Pages, select GitHub Actions. 3. Run the `Deploy GitHub Pages` workflow or push to `main`. -The workflow runs `npm test`, builds `dist`, and publishes it as a Pages artifact. +The workflow runs the test and coverage gates, builds `dist`, verifies both HTML shells and their shared hashed assets, and publishes the result as a Pages artifact. ## Online Backend diff --git a/README.ru.md b/README.ru.md index 4bd371b..dbc2f66 100644 --- a/README.ru.md +++ b/README.ru.md @@ -6,6 +6,8 @@ Публичная версия: https://agent-axiom.github.io/agents-salvo/ +Публичный Telegram Mini App: https://agent-axiom.github.io/agents-salvo/telegram/ + ![Иллюстрация бумажного поля Залпа](src/assets/salvo-board-action.png) ## Возможности @@ -34,6 +36,16 @@ npm start После `npm start` открыть `http://localhost:5173`. +## Telegram Mini App + +В [@BotFather](https://t.me/BotFather) нужно выбрать бота «Залпа», открыть настройку Main Mini App и указать URL `https://agent-axiom.github.io/agents-salvo/telegram/`. + +При запуске внутри Telegram Mini App автоматически отправляет подписанный Telegram `initData` в Cloudflare Worker. Worker проверяет подпись и срок действия данных, а затем создаёт существующую сессию «Залпа», поэтому отдельный вход не требуется. + +Браузер, Telegram Mini App, iOS-приложение и Android-приложение используют единое дерево исходного кода и одну команду `npm run build`. Эта сборка создаёт обычный и Telegram HTML shell с общими хешированными JavaScript bundle и stylesheet; Telegram SDK загружается только в Telegram shell. + +Pages и Mini App обновляются сразу после публикации Pages artifact. Нативные приложения не загружают Pages при старте: каждый APK или iOS app содержит сборку выбранного коммита и меняется только после упаковки и выпуска этого коммита. + ## Разработка приложений для iOS и Android Мобильные приложения используют тот же локально собранный `dist/`, что и GitHub Pages, и не загружают публичный сайт при старте. Поэтому бои с агентом, тренировки и PvP на одном устройстве работают без интернета. Для online-комнат, Telegram-авторизации, профилей и лидерборда нужен доступ к Cloudflare Worker. @@ -90,7 +102,7 @@ API-публикация доступна после появления перв 2. В Settings -> Pages выбрать GitHub Actions. 3. Запустить workflow `Deploy GitHub Pages` или сделать push в `main`. -Workflow прогоняет `npm test`, собирает `dist` и публикует его как Pages artifact. +Workflow прогоняет тесты и coverage gates, собирает `dist`, проверяет оба HTML shell и общие хешированные артефакты и публикует результат как Pages artifact. ## Online backend diff --git a/README.zh-CN.md b/README.zh-CN.md index 89386eb..3f9644d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,6 +6,8 @@ 在线版本:https://agent-axiom.github.io/agents-salvo/ +Telegram Mini App:https://agent-axiom.github.io/agents-salvo/telegram/ + ![纸上海战棋棋盘插图](src/assets/salvo-board-action.png) ## 功能 @@ -34,6 +36,16 @@ npm start 运行 `npm start` 后打开 `http://localhost:5173`。 +## Telegram Mini App + +在 [@BotFather](https://t.me/BotFather) 中选择齐射机器人,打开 Main Mini App 设置,并将 URL 设为 `https://agent-axiom.github.io/agents-salvo/telegram/`。 + +从 Telegram 内打开时,Mini App 会自动将 Telegram 签名的 `initData` 发送到 Cloudflare Worker。Worker 验证签名和数据时效后创建现有的齐射会话,因此玩家无需再次登录。 + +浏览器、Telegram Mini App、iOS 应用和 Android 应用共享同一份源代码,并由一次 `npm run build` 生成。该构建会输出普通 HTML shell 和 Telegram HTML shell,两者引用同一组带哈希的 JavaScript bundle 和 stylesheet;只有 Telegram shell 加载 Telegram SDK。 + +发布 Pages artifact 后,Pages 和 Mini App 会立即更新。原生应用启动时不会加载 Pages:每个 APK 或 iOS 应用会打包所选提交的构建,只有在该提交完成打包和发布后才会更新。 + ## iOS 和 Android 开发 移动应用打包的 `dist/` 与 GitHub Pages 使用同一份构建产物,启动时不会加载公开网站。因此,智能体对战、训练和同机双人模式可离线运行。在线房间、Telegram 登录、玩家档案和排行榜需要连接 Cloudflare Worker。 @@ -90,7 +102,7 @@ RuStore 中出现首个已上线版本后,才可使用 API 发布。先运行 2. 在 Settings -> Pages 中选择 GitHub Actions。 3. 运行 `Deploy GitHub Pages` workflow,或推送到 `main`。 -Workflow 会运行 `npm test`,构建 `dist`,并发布为 Pages artifact。 +Workflow 会运行测试和 coverage gates,构建 `dist`,验证两个 HTML shell 及其共享的带哈希资源,并将结果发布为 Pages artifact。 ## 在线后端 diff --git a/docs/superpowers/plans/2026-07-17-telegram-mini-app.md b/docs/superpowers/plans/2026-07-17-telegram-mini-app.md new file mode 100644 index 0000000..2261bcd --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-telegram-mini-app.md @@ -0,0 +1,918 @@ +# Telegram Mini App Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver the complete Salvo game as a Telegram Main Mini App from the same source and build used by GitHub Pages and the Capacitor Android/iOS applications. + +**Architecture:** Add a dedicated Telegram runtime adapter and HTML shell around the existing shared game bundle. Authenticate Mini App launches through a new Cloudflare Worker endpoint that validates raw `initData` and creates the existing D1-backed Salvo session, preserving the same `telegram:` player identity across every client. + +**Tech Stack:** ES modules, esbuild, Node.js test runner and coverage, Telegram Mini Apps JavaScript API, Cloudflare Workers, Durable Objects, D1, Capacitor 8, GitHub Actions. + +--- + +## File Map + +**Create** + +- `worker/telegram-mini-app-auth.js`: strict parsing, signature verification, + freshness checks, and Telegram user normalization for Mini App launch data. +- `tests/telegram-mini-app-auth.test.mjs`: cryptographic verifier tests. +- `tests/telegram-mini-app-worker.test.mjs`: endpoint and D1 session tests. +- `src/telegram-mini-app-auth.js`: bounded frontend exchange client. +- `tests/telegram-mini-app-client.test.mjs`: frontend auth client tests. +- `src/platform/telegram.js`: Telegram runtime adapter. +- `tests/telegram-platform.test.mjs`: deterministic WebApp adapter tests. +- `src/telegram/index.html`: Telegram-only bootstrap shell loading the shared app. +- `tests/telegram-build.test.mjs`: build-output and shared-artifact tests. +- `src/telegram-launch.js`: strict `startapp` parsing and link construction. +- `tests/telegram-launch.test.mjs`: room and replay launch tests. + +**Modify** + +- `worker/index.js`: route and handle `/auth/telegram/miniapp`. +- `src/platform/index.js`: select Telegram between native and web runtimes. +- `src/platform/web.js`: expose runtime-compatible no-op capabilities. +- `src/platform/native.js`: expose runtime-compatible no-op capabilities. +- `src/app.js`: automatic Mini App auth, fallback UI, Telegram launch routing, + settings/back state, lifecycle-ready notification, and Telegram sharing. +- `src/mobile.js`: invoke runtime-ready after the shared UI is usable. +- `src/mobile-app-support.js`: parse Telegram launch actions independently from + web and native deep links. +- `src/index.html`: declare the regular runtime explicitly. +- `src/styles.css`: Telegram safe areas, fallback screen, and full-width boards. +- `src/i18n.js`: Mini App auth, fallback, room, and reopen messages in RU/EN/ZH. +- `src/privacy.html`: disclose server validation of Mini App launch data. +- `scripts/build.mjs`: emit two HTML shells referencing one hashed JS/CSS pair. +- `tests/mobile-build.test.mjs`: accept and verify hashed shared artifacts. +- `tests/platform.test.mjs`: cover runtime selection compatibility. +- `tests/app-behavior.test.mjs`: register Mini App scenarios. +- `tests/app-behavior-harness.mjs`: exercise automatic auth and launch routing. +- `tests/auth-ui.test.mjs`: assert Mini App login UI and privacy behavior. +- `.github/workflows/pages.yml`: assert Telegram build output before deployment. +- `.github/workflows/mobile.yml`: assert the same output before native sync. +- `README.md`, `README.ru.md`, `README.zh-CN.md`: document Mini App launch and + the one-source build model. + +## Task 1: Verify Telegram Mini App Launch Data + +**Files:** +- Create: `worker/telegram-mini-app-auth.js` +- Create: `tests/telegram-mini-app-auth.test.mjs` + +- [ ] **Step 1: Write the cryptographic happy-path test** + +Create a test that signs the exact raw values Telegram sends and checks the +normalized public user: + +```js +test("Mini App initData verifies and normalizes the Telegram user", async () => { + const botToken = "123456:test-bot-token"; + const user = JSON.stringify({ + id: 8710001168, + first_name: "Dima", + last_name: "Kosarevsky", + username: "agent_axiom", + language_code: "ru", + photo_url: "https://t.me/i/userpic/320/avatar.jpg", + }); + const initData = await signInitData({ + auth_date: "1784232000", + query_id: "AAHdF6IQAAAAAN0XohDhrOrc", + start_param: "room_ABCD", + user, + }, botToken); + + assert.deepEqual( + await verifyTelegramMiniAppInitData(initData, botToken, { + now: 1784232120, + maxAgeSeconds: 300, + maxFutureSeconds: 60, + }), + { + user: { + provider: "telegram", + id: "8710001168", + name: "Dima Kosarevsky", + username: "agent_axiom", + photoUrl: "https://t.me/i/userpic/320/avatar.jpg", + }, + languageCode: "ru", + startParam: "room_ABCD", + }, + ); +}); +``` + +The test helper must derive the Mini App key by signing the bot token with +`WebAppData`, then sign the alphabetical data-check string with that key. + +- [ ] **Step 2: Run the focused test and confirm the missing module failure** + +Run: + +```sh +node --test tests/telegram-mini-app-auth.test.mjs +``` + +Expected: FAIL because `worker/telegram-mini-app-auth.js` does not exist. + +- [ ] **Step 3: Implement strict parsing and HMAC verification** + +Create an exported verifier with these defaults and boundaries: + +```js +export async function verifyTelegramMiniAppInitData( + rawInitData, + botToken, + { + now = Math.floor(Date.now() / 1000), + maxAgeSeconds = 300, + maxFutureSeconds = 60, + } = {}, +) { + const fields = parseInitData(rawInitData); + const suppliedHash = requireHexHash(fields.get("hash")); + const dataCheckString = [...fields] + .filter(([key]) => key !== "hash") + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + const secret = await hmac(textEncoder.encode("WebAppData"), botToken); + const expectedHash = bytesToHex(await hmac(secret, dataCheckString)); + if (!timingSafeEqualHex(expectedHash, suppliedHash)) throw authError(); + + const authDate = strictEpoch(fields.get("auth_date")); + if (now - authDate > maxAgeSeconds || authDate - now > maxFutureSeconds) { + throw authError(); + } + return normalizeResult(fields); +} +``` + +`parseInitData` must reject empty input, input over 16 KiB, duplicate keys, +missing `hash`, `auth_date`, or `user`, malformed percent encoding, and keys +outside this current WebAppInitData allowlist: + +```js +const allowedFields = new Set([ + "auth_date", "can_send_after", "chat", "chat_instance", "chat_type", + "hash", "query_id", "receiver", "signature", "start_param", "user", +]); +``` + +The HMAC data-check string includes every supplied field except `hash`, including +the optional Telegram `signature` field. `normalizeResult` must require a +non-bot integer ID representable within Telegram's documented 52-bit range, +bound all strings, allow only `https:` photo URLs, and return `publicUser` shape. + +- [ ] **Step 4: Add adversarial verifier tests** + +Cover all of these cases with explicit `assert.rejects` calls: + +```js +for (const mutate of [ + (value) => value.replace("Dima", "Mallory"), + (value) => `${value}&auth_date=1784232000`, + (value) => value.replace("auth_date=1784232000", "auth_date=1784231000"), + (value) => value.replace("auth_date=1784232000", "auth_date=1784232200"), + (value) => value.replace(/hash=[^&]+/, "hash=not-hex"), +]) { + await assert.rejects( + () => verifyTelegramMiniAppInitData(mutate(valid), botToken, { now: 1784232120 }), + /Telegram Mini App authentication failed/, + ); +} +``` + +Also test missing bot token, malformed JSON user, unknown top-level keys, +unsupported URL schemes, overlong names, and a raw string over 16 KiB. Error +messages must remain generic and must not contain raw launch data. + +- [ ] **Step 5: Run verifier tests and the existing auth tests** + +Run: + +```sh +node --test tests/telegram-mini-app-auth.test.mjs tests/auth.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the verifier** + +```sh +git add worker/telegram-mini-app-auth.js tests/telegram-mini-app-auth.test.mjs +git commit -m "feat: verify Telegram Mini App launches" +``` + +## Task 2: Exchange Mini App Launches for Existing Salvo Sessions + +**Files:** +- Create: `tests/telegram-mini-app-worker.test.mjs` +- Modify: `worker/index.js` + +- [ ] **Step 1: Write endpoint success and identity-continuity tests** + +Use the existing in-memory D1 harness and session schema. Post signed data to +the new route and verify the opaque session resolves through `/auth/me`: + +```js +const response = await worker.fetch( + new Request("https://worker.test/auth/telegram/miniapp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ initData }), + }), + { DB: db, TELEGRAM_BOT_TOKEN: botToken }, +); +assert.equal(response.status, 200); +const payload = await response.json(); +assert.match(payload.token, /^[A-Za-z0-9_-]{43}$/); +assert.equal(db.queryOne("SELECT user_key FROM auth_sessions").user_key, "telegram:8710001168"); + +const me = await worker.fetch(new Request("https://worker.test/auth/me", { + headers: { Authorization: `Bearer ${payload.token}` }, +}), { DB: db }); +assert.deepEqual(await me.json(), { user: payload.user }); +``` + +- [ ] **Step 2: Run the endpoint test and confirm a 404** + +Run: + +```sh +node --test tests/telegram-mini-app-worker.test.mjs +``` + +Expected: FAIL because `/auth/telegram/miniapp` is not routed. + +- [ ] **Step 3: Add an exact route and handler** + +Add the route before the generic `/auth/telegram` route: + +```js +if (url.pathname === "/auth/telegram/miniapp") { + return { kind: "authTelegramMiniApp" }; +} +``` + +Handle only `POST`, extend the existing strict JSON reader to accept an explicit +maximum and call it as `readStrictTelegramJson(request, "initData", 16 * 1024)`. +Keep the existing OIDC calls on their current 1024-byte default. Call +`verifyTelegramMiniAppInitData`, pass the returned user to +`createSession(env.DB, user)`, and return the existing `{ token, user }` shape. +Every verification, parsing, database, or configuration failure returns: + +```js +json({ error: "Telegram Mini App authentication failed" }, 401) +``` + +- [ ] **Step 4: Add endpoint rejection and redaction tests** + +Test GET, wrong content type, extra JSON keys, missing DB/token, malformed and +oversized bodies, stale launch data, tampered launch data, and a D1 failure. +Assert response bodies never contain the bot token, initData, hash, query ID, or +Telegram user JSON. + +- [ ] **Step 5: Run Worker auth suites** + +Run: + +```sh +node --test tests/telegram-mini-app-worker.test.mjs tests/telegram-oidc-worker.test.mjs tests/auth.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the endpoint** + +```sh +git add worker/index.js tests/telegram-mini-app-worker.test.mjs +git commit -m "feat: authenticate Telegram Mini App users" +``` + +## Task 3: Add the Bounded Mini App Auth Client and Launch Parser + +**Files:** +- Create: `src/telegram-mini-app-auth.js` +- Create: `tests/telegram-mini-app-client.test.mjs` +- Create: `src/telegram-launch.js` +- Create: `tests/telegram-launch.test.mjs` + +- [ ] **Step 1: Write client request and response validation tests** + +Test one exact POST and the normalized response: + +```js +const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + fetcher: async (url, init) => { + assert.equal(url, "https://worker.test/auth/telegram/miniapp"); + assert.deepEqual(JSON.parse(init.body), { initData: "signed-launch-data" }); + return jsonResponse({ token: "a".repeat(43), user: telegramUser }); + }, +}); +assert.deepEqual(await client.authenticate("signed-launch-data"), { + token: "a".repeat(43), + user: telegramUser, +}); +``` + +Also test a 10-second timeout, caller abort, non-JSON response, response larger +than 16 KiB, invalid token, invalid user, and generic redacted errors. Reuse the +bounded-reader style from `src/telegram-auth.js` without exporting or coupling +to its OIDC-specific URL validation. + +- [ ] **Step 2: Run the client test and confirm the missing module failure** + +```sh +node --test tests/telegram-mini-app-client.test.mjs +``` + +Expected: FAIL because the client module does not exist. + +- [ ] **Step 3: Implement `createTelegramMiniAppAuthClient`** + +Expose only: + +```js +export function createTelegramMiniAppAuthClient({ workerUrl, fetcher, timeoutMs = 10_000 }) { + return { + authenticate(initData, { signal } = {}) { + return requestJson( + `${normalizeWorkerUrl(workerUrl)}/auth/telegram/miniapp`, + { initData: requireInitData(initData) }, + { fetcher, timeoutMs, signal }, + ); + }, + }; +} +``` + +Require non-empty initData no larger than 16 KiB. Keep the returned token and +user validation equivalent to the existing Telegram auth client. + +- [ ] **Step 4: Write strict launch parser tests** + +```js +assert.deepEqual(parseTelegramStartParam("room_ABCD"), { + type: "room", roomCode: "ABCD", +}); +assert.deepEqual(parseTelegramStartParam("replay_replay-123"), { + type: "replay", replayId: "replay-123", +}); +for (const value of ["room_abcd", "room_ABC", "room_ABCD?x", "replay_", "menu", ""]) { + assert.equal(parseTelegramStartParam(value), null); +} +assert.equal( + telegramRoomInviteUrl("agents_salvo_bot", "ABCD"), + "https://t.me/agents_salvo_bot?startapp=room_ABCD", +); +``` + +- [ ] **Step 5: Implement launch parsing and canonical link construction** + +Create pure functions with exact regular expressions: + +```js +const roomStartPattern = /^room_([A-Z0-9]{4,12})$/; +const replayStartPattern = /^replay_([A-Za-z0-9-]{1,128})$/; +``` + +Validate the bot username with `/^[A-Za-z][A-Za-z0-9_]{4,31}$/` and construct +links through `URL` and `URLSearchParams`, never string concatenation with user +input. + +- [ ] **Step 6: Run client and launch tests** + +```sh +node --test tests/telegram-mini-app-client.test.mjs tests/telegram-launch.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the client and launch module** + +```sh +git add src/telegram-mini-app-auth.js src/telegram-launch.js tests/telegram-mini-app-client.test.mjs tests/telegram-launch.test.mjs +git commit -m "feat: add Telegram Mini App bootstrap clients" +``` + +## Task 4: Implement the Telegram Runtime Adapter + +**Files:** +- Create: `src/platform/telegram.js` +- Create: `tests/telegram-platform.test.mjs` +- Modify: `src/platform/index.js` +- Modify: `src/platform/web.js` +- Modify: `src/platform/native.js` +- Modify: `tests/platform.test.mjs` + +- [ ] **Step 1: Write adapter contract tests using a fake WebApp** + +The fake records `ready`, `expand`, fullscreen, colors, BackButton, +SettingsButton, events, links, and haptics. Assert the public contract: + +```js +const adapter = createTelegramPlatform({ + webApp: fake.webApp, + window: fake.window, + navigator: { onLine: true }, + storage: fake.storage, +}); +assert.equal(adapter.isNative(), false); +assert.equal(adapter.getPlatform(), "telegram"); +assert.equal(adapter.isAvailable(), true); +assert.equal(adapter.getLaunchData(), "signed-init-data"); +assert.equal(adapter.getStartParam(), "room_ABCD"); +``` + +Test listener registration and cleanup for BackButton, SettingsButton, +`activated`, `deactivated`, `themeChanged`, `viewportChanged`, +`safeAreaChanged`, and `contentSafeAreaChanged`. + +- [ ] **Step 2: Run the adapter test and confirm the missing module failure** + +```sh +node --test tests/telegram-platform.test.mjs +``` + +Expected: FAIL because `src/platform/telegram.js` does not exist. + +- [ ] **Step 3: Implement the adapter without game imports** + +Return the shared platform methods plus these runtime-neutral additions: + +```js +isAvailable() +getLaunchData() +getStartParam() +onSettings(listener) +ready() +setClosingConfirmation(enabled) +getTheme() +onThemeChange(listener) +onViewportChange(listener) +``` + +Use an in-memory `secureSession` so Mini App session tokens are not persisted. +Use prefixed localStorage for non-sensitive settings. Map semantic haptics to +Telegram impact and notification methods. Check `isVersionAtLeast("8.0")` +before fullscreen and safe-area-specific operations. All optional methods must +catch provider failures and preserve gameplay. + +- [ ] **Step 4: Add Telegram runtime selection while preserving old calls** + +Keep `selectPlatform(false)` and `selectPlatform(true)` working for existing +tests. Add injectable runtime context as a second argument: + +```js +export function selectPlatform( + isNative = Capacitor.isNativePlatform(), + { + runtime = globalThis.document?.documentElement?.dataset?.runtime, + telegramWebApp = globalThis.window?.Telegram?.WebApp, + } = {}, +) { + if (isNative) return createNativePlatform(); + if (runtime === "telegram") return createTelegramPlatform({ webApp: telegramWebApp }); + return createWebPlatform(); +} +``` + +Add safe no-op implementations of the new optional capabilities to web and +native adapters so shared code never branches on method existence. + +- [ ] **Step 5: Run platform suites** + +```sh +node --test tests/telegram-platform.test.mjs tests/platform.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the runtime adapter** + +```sh +git add src/platform tests/platform.test.mjs tests/telegram-platform.test.mjs +git commit -m "feat: add Telegram Mini App runtime" +``` + +## Task 5: Produce One Shared Build with Two HTML Shells + +**Files:** +- Create: `src/telegram/index.html` +- Create: `tests/telegram-build.test.mjs` +- Modify: `src/index.html` +- Modify: `scripts/build.mjs` +- Modify: `tests/mobile-build.test.mjs` + +- [ ] **Step 1: Write build assertions before changing the build** + +Build into a temporary directory and assert: + +```js +assert.equal(existsSync(join(output, "index.html")), true); +assert.equal(existsSync(join(output, "telegram/index.html")), true); +const web = readFileSync(join(output, "index.html"), "utf8"); +const telegram = readFileSync(join(output, "telegram/index.html"), "utf8"); +const webApp = web.match(/src="\.\/(app\.[a-f0-9]{10}\.js)"/)?.[1]; +const telegramApp = telegram.match(/src="\.\.\/(app\.[a-f0-9]{10}\.js)"/)?.[1]; +assert.equal(webApp, telegramApp); +assert.match(telegram, /https:\/\/telegram\.org\/js\/telegram-web-app\.js\?63/); +assert.match(telegram, /data-runtime="telegram"/); +assert.doesNotMatch(web, /telegram-web-app\.js/); +``` + +Make the equivalent assertion for `styles..css` and verify Capacitor's +root shell does not reference the Telegram SDK. + +- [ ] **Step 2: Run the build test and confirm the missing shell failure** + +```sh +node --test tests/telegram-build.test.mjs +``` + +Expected: FAIL because the Telegram shell and hashed references are absent. + +- [ ] **Step 3: Add the Telegram shell** + +Create a full HTML document with the same metadata and `SALVO_CONFIG` values as +the regular shell. Set ``, use `../` paths, and +load the official Telegram SDK before `../app.js`: + +```html + + +``` + +Set `` on the regular shell. + +- [ ] **Step 4: Hash the shared JS and CSS outputs and rewrite both shells** + +After bundling `app.js`, calculate the first ten lowercase hex characters of +SHA-256 for the bundle and stylesheet. Rename them to `app..js` and +`styles..css`, rename the sourcemap consistently, update the bundle's +source map comment, and rewrite only the exact shell references. Use Node +`crypto.createHash`, `fs.rename`, and exact string replacement; reject a shell +when the expected original reference appears zero or multiple times. + +Read `SALVO_BUILD_ID`, require `/^[A-Za-z0-9._-]{1,64}$/`, and use `dev` when it +is absent. Replace the exact `buildId: "dev"` marker in both shells so every +runtime reports the source revision without producing a second application +bundle. + +- [ ] **Step 5: Update existing mobile build assertions** + +Replace assumptions about exact source HTML and `dist/app.js` with discovery of +the hashed root references. Continue asserting that the bundle contains no +unresolved Capacitor or relative JavaScript imports and that all native assets +remain local. + +- [ ] **Step 6: Run build suites and Capacitor sync** + +```sh +node --test tests/telegram-build.test.mjs tests/mobile-build.test.mjs +npm run mobile:verify +``` + +Expected: PASS and successful Capacitor sync for both native projects. + +- [ ] **Step 7: Commit the shared build** + +```sh +git add src/index.html src/telegram/index.html scripts/build.mjs tests/telegram-build.test.mjs tests/mobile-build.test.mjs +git commit -m "feat: build shared Telegram Mini App shell" +``` + +## Task 6: Integrate Automatic Authentication and Telegram Navigation + +**Files:** +- Modify: `src/app.js` +- Modify: `src/mobile.js` +- Modify: `tests/app-behavior.test.mjs` +- Modify: `tests/app-behavior-harness.mjs` +- Modify: `tests/auth-ui.test.mjs` + +- [ ] **Step 1: Add a Mini App behavior scenario that fails first** + +Register `telegram-bootstrap` in the child harness. Inject a Telegram platform +with launch data and assert: + +```js +const app = bootSalvoApp(harness.dependencies); +await waitFor(() => harness.fetchCalls.some(({ url }) => + url.endsWith("/auth/telegram/miniapp"))); +assert.deepEqual(JSON.parse(authRequest.init.body), { initData: "signed-init-data" }); +authResponse.resolve(response({ token: sessionToken, user: telegramUser })); +await app.startup.authReady; +assert.equal(app.getState().auth.user.id, telegramUser.id); +assert.equal(app.getState().auth.token, sessionToken); +assert.equal(harness.calls.secureWrites, 1); +assert.doesNotMatch(harness.root.innerHTML, /auth-telegram-oidc|telegram-login-slot/); +``` + +Because Telegram secureSession is in memory, the write assertion confirms only +runtime memory storage and no browser localStorage call. + +- [ ] **Step 2: Run the behavior scenario and verify failure** + +```sh +SALVO_APP_BEHAVIOR_SCENARIO=telegram-bootstrap SALVO_APP_CHILD_COVERAGE=isolated node tests/app-behavior-harness.mjs +``` + +Expected: FAIL because Mini App automatic authentication is not wired. + +- [ ] **Step 3: Add Mini App auth bootstrap to `bootSalvoApp`** + +Create the Mini App client only when `platform.getPlatform() === "telegram"`. +Replace the normal capability/OIDC startup branch with: + +```js +async function authenticateTelegramMiniApp() { + if (!platform.isAvailable() || !platform.getLaunchData()) { + state.auth.method = "miniapp-unavailable"; + state.auth.error = translate("auth.miniAppOpenInTelegram"); + render(); + return false; + } + state.auth.method = "miniapp"; + const result = await telegramMiniAppClient.authenticate(platform.getLaunchData()); + return secureSessionCoordinator.establish(result.token, () => { + state.auth.token = result.token; + state.auth.user = result.user; + state.auth.error = ""; + }); +} +``` + +The Telegram branch runs after the runtime network sample and before profile or +private launch routing. Web/native OIDC capability loading remains unchanged. +Logout clears the in-memory token and immediately re-authenticates only after an +explicit retry or Mini App reopen, avoiding a logout loop. + +Use `platform.getTheme()` as the initial light/dark value in Telegram. Track +whether a stored or user-selected Salvo theme exists. Telegram `themeChanged` +events update the game only while that flag is false; an explicit Salvo theme +selection remains authoritative. + +- [ ] **Step 4: Connect Telegram BackButton, SettingsButton, lifecycle, and ready** + +Use the existing `handlePlatformBack` callback for BackButton. SettingsButton +sets `state.settingsOpen = true` and renders. Toggle closing confirmation from +the same predicate that guards unfinished local battles. Call `platform.ready()` +after the first usable render and ensure it is idempotent. Lifecycle continues +through `createMobileRuntime`, so Telegram `activated/deactivated` pauses and +resumes audio without a second code path. + +Render the validated `SALVO_CONFIG.buildId` in the settings metadata for web, +native, and Telegram, using `dev` when the source shell is served directly. + +- [ ] **Step 5: Add fallback and race tests** + +Cover missing SDK/initData, rejected auth, auth retry, stale auth completion +after logout, platform stop cleanup, and a failed in-memory session write. +Assert local mode buttons remain enabled while online/profile remain gated. + +- [ ] **Step 6: Run application behavior and auth UI suites** + +```sh +node --test tests/app-behavior.test.mjs tests/auth-ui.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 7: Commit automatic auth and navigation** + +```sh +git add src/app.js src/mobile.js tests/app-behavior.test.mjs tests/app-behavior-harness.mjs tests/auth-ui.test.mjs +git commit -m "feat: bootstrap Salvo inside Telegram" +``` + +## Task 7: Route Invites and Replays Through Telegram + +**Files:** +- Modify: `src/app.js` +- Modify: `src/mobile-app-support.js` +- Modify: `tests/mobile-app-support.test.mjs` +- Modify: `tests/app-behavior.test.mjs` +- Modify: `tests/app-behavior-harness.mjs` + +- [ ] **Step 1: Write a launch-routing scenario** + +Inject `getStartParam() === "room_ABCD"`, complete automatic auth, and assert the +app opens online with `roomCodeInput === "ABCD"` and calls the existing join +workflow. Add a replay case asserting the private replay request occurs only +after authentication. + +- [ ] **Step 2: Run the new scenarios and verify failure** + +```sh +node --test tests/app-behavior.test.mjs +``` + +Expected: FAIL because Telegram start parameters are not applied. + +- [ ] **Step 3: Add guarded launch coordination** + +Convert a valid parsed Telegram start parameter to the existing internal room +or replay navigation action. Route it through `createAppNavigationCoordinator` +so an unfinished battle receives the existing leave confirmation. Process the +launch exactly once after Mini App auth settles; invalid values open the menu. + +- [ ] **Step 4: Generate Telegram-native share links** + +In Mini App mode, room share uses `telegramRoomInviteUrl` instead of the Pages +URL and passes this URL to the platform adapter. The adapter opens: + +```text +https://t.me/share/url?url=&text= +``` + +Replay share uses the equivalent `startapp=replay_` link. Web and native +sharing retain their current canonical Pages/deep-link behavior. + +- [ ] **Step 5: Add invalid, full-room, and share fallback tests** + +Assert lower-case/oversized room params and malformed replay IDs never navigate. +Assert join failure remains visible in the online lobby. Assert failed Telegram +sharing returns `{ shared: false }`, allowing the existing fallback status. + +- [ ] **Step 6: Run navigation, launch, and behavior tests** + +```sh +node --test tests/telegram-launch.test.mjs tests/mobile-app-support.test.mjs tests/app-behavior.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 7: Commit launch routing and sharing** + +```sh +git add src/app.js src/mobile-app-support.js tests/mobile-app-support.test.mjs tests/app-behavior.test.mjs tests/app-behavior-harness.mjs +git commit -m "feat: open Telegram rooms and replays" +``` + +## Task 8: Add Telegram-Safe Layout, Localization, and Privacy Text + +**Files:** +- Modify: `src/styles.css` +- Modify: `src/i18n.js` +- Modify: `src/privacy.html` +- Modify: `tests/privacy.test.mjs` +- Modify: `tests/auth-ui.test.mjs` + +- [ ] **Step 1: Write static assertions for three-language copy and safe areas** + +Add tests that require translation keys for open-in-Telegram, expired launch, +auth retry, room failure, and Mini App account status in `ru`, `en`, and `zh-CN`. +Assert CSS consumes Telegram variables with fallbacks: + +```css +--salvo-safe-top: var(--tg-content-safe-area-inset-top, env(safe-area-inset-top, 0px)); +--salvo-safe-right: var(--tg-content-safe-area-inset-right, env(safe-area-inset-right, 0px)); +--salvo-safe-bottom: var(--tg-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); +--salvo-safe-left: var(--tg-content-safe-area-inset-left, env(safe-area-inset-left, 0px)); +``` + +- [ ] **Step 2: Run static UI/privacy tests and verify failure** + +```sh +node --test tests/auth-ui.test.mjs tests/privacy.test.mjs +``` + +Expected: FAIL because Mini App copy and disclosure are absent. + +- [ ] **Step 3: Implement responsive Telegram styling** + +Use runtime-scoped selectors under `html[data-runtime="telegram"]`. Apply safe +padding to the app shell and dialogs, use `--tg-viewport-stable-height` as a +minimum available-height input, and preserve the existing phone rule that makes +boards width-constrained with `overflow-x: clip`. Do not duplicate board styles +or reduce cell labels below the current mobile values. + +- [ ] **Step 4: Add localized copy and privacy disclosure** + +Add complete RU/EN/ZH strings. Update each privacy section to state that the +Mini App sends signed Telegram launch data to the Worker for identity validation, +does not persist raw launch data, and reuses the same profile records. + +- [ ] **Step 5: Run UI/privacy tests** + +```sh +node --test tests/auth-ui.test.mjs tests/privacy.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit UI and privacy changes** + +```sh +git add src/styles.css src/i18n.js src/privacy.html tests/auth-ui.test.mjs tests/privacy.test.mjs +git commit -m "feat: polish Telegram Mini App experience" +``` + +## Task 9: CI, Documentation, Full Verification, and Deployment + +**Files:** +- Modify: `.github/workflows/pages.yml` +- Modify: `.github/workflows/mobile.yml` +- Modify: `README.md` +- Modify: `README.ru.md` +- Modify: `README.zh-CN.md` +- Modify: `tests/mobile-build.test.mjs` + +- [ ] **Step 1: Add workflow and documentation assertions** + +Extend existing build tests to require `dist/telegram/index.html`, the shared +hashed assets, and the official Telegram SDK only in the Telegram shell. Add +README assertions or direct content checks for the public launch URL and the +single-source build explanation. + +- [ ] **Step 2: Run the focused build tests and verify failure** + +```sh +node --test tests/telegram-build.test.mjs tests/mobile-build.test.mjs +``` + +Expected: FAIL until workflow/documentation expectations are updated. + +- [ ] **Step 3: Update CI and documentation** + +Keep the existing test and coverage gates. Add a named `Verify Telegram Mini +App artifacts` step after `npm run build` in Pages and mobile web jobs: + +```sh +test -f dist/telegram/index.html +grep -q 'telegram-web-app.js' dist/telegram/index.html +``` + +Set `SALVO_BUILD_ID: ${{ github.sha }}` on CI build steps and assert the emitted +shells contain that exact value. Local builds continue to emit `dev`. + +Document the Mini App URL, BotFather Main Mini App setup, automatic auth model, +and that Pages/Mini App update immediately while native stores package a chosen +commit. + +- [ ] **Step 4: Run all automated verification** + +```sh +npm test +npm run coverage +npm run build +npm run mobile:verify +android/gradlew -p android test lint assembleDebug +``` + +Expected: every command exits zero; coverage retains the existing 98% core line +gate and the app behavior gate; Android debug assembly succeeds. + +- [ ] **Step 5: Inspect the built shells and repository diff** + +```sh +git diff --check +git status --short +``` + +Expected: no whitespace errors and only planned files changed. + +- [ ] **Step 6: Commit CI and documentation** + +```sh +git add .github/workflows/pages.yml .github/workflows/mobile.yml README.md README.ru.md README.zh-CN.md tests/mobile-build.test.mjs +git commit -m "docs: document Telegram Mini App delivery" +``` + +- [ ] **Step 7: Deploy the Worker and smoke-test the endpoint** + +Run: + +```sh +npx wrangler deploy +curl -i -X POST https://agents-salvo-room.if-ab6.workers.dev/auth/telegram/miniapp \ + -H 'Content-Type: application/json' \ + --data '{"initData":"invalid"}' +``` + +Expected: deploy succeeds and the invalid smoke request returns `401` with the +generic Mini App authentication error and no sensitive data. + +- [ ] **Step 8: Publish the branch and verify GitHub Actions** + +Push the implementation branch, create a pull request, and wait for Pages, +mobile, and coverage checks. After merge, verify: + +```text +https://agent-axiom.github.io/agents-salvo/telegram/ +``` + +Expected: the route loads the Telegram fallback outside Telegram and the normal +Pages route remains unchanged. + +- [ ] **Step 9: Configure BotFather after the deployed route is verified** + +In `@BotFather`, configure `@agents_salvo_bot` as the Main Mini App with URL +`https://agent-axiom.github.io/agents-salvo/telegram/`, request short name +`salvo` with `agents_salvo` as fallback, set the menu action to Play, and upload +localized RU/EN/ZH metadata. Then perform the approved Android, iOS, and Desktop +manual matrix before announcing availability. diff --git a/docs/superpowers/specs/2026-07-17-telegram-mini-app-design.md b/docs/superpowers/specs/2026-07-17-telegram-mini-app-design.md new file mode 100644 index 0000000..dbbbd9f --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-telegram-mini-app-design.md @@ -0,0 +1,379 @@ +# Telegram Mini App Design + +**Date:** 2026-07-17 +**Status:** Approved for implementation planning + +## Goal + +Ship the complete Salvo game as a Telegram Mini App while retaining one source +of truth for game rules, UI, styles, localizations, audio, and visual assets. +The same source tree must produce the GitHub Pages site, the Telegram Mini App, +and the bundled Capacitor applications for Android and iOS. + +The Mini App includes all current game modes and account features. It must feel +native inside Telegram, authenticate the Telegram user automatically, and reuse +the same player identity, profile, leaderboard, achievements, online rooms, and +private replay archive as the existing web and native clients. + +## Product Decisions + +- Include agent battles, authenticated online play, same-device play, training, + every rules preset, profiles, leaderboard, achievements, and replays. +- Maintain one implementation of game behavior and one shared application UI. +- Keep the game free and do not add advertising, purchases, or paid advantages. +- Host the first Mini App release on the existing GitHub Pages deployment. +- Configure `@agents_salvo_bot` as the Main Mini App and expose a bot menu entry. +- Use Telegram launch parameters for room invitations and replay links. +- Preserve the existing Telegram OIDC flows for the website and native apps. +- Authenticate Mini App users with server-validated `Telegram.WebApp.initData`. +- Keep the primary game commands in the shared game UI. Use Telegram-native UI + only for navigation, settings entry, confirmations, sharing, viewport control, + lifecycle, and haptics. + +## Alternatives Considered + +### Separate Telegram fork + +A fork would allow unrestricted Telegram-specific changes, but every game bug, +rules change, localization update, and asset improvement would need to be copied +between projects. The resulting drift and regression risk make this unsuitable. + +### Target-specific application bundles + +Conditional bundles could remove unused platform code, but would create several +build products with subtly different behavior and broaden the test matrix. The +current application is small enough that this optimization has no product value. + +### Shared application bundle with thin runtime shells + +This is the selected approach. The website and Telegram route load the same +hashed JavaScript and CSS outputs. Capacitor packages the same application build +from the root web shell. Only runtime adapters and the HTML bootstrap differ. + +## Architecture + +The existing core and UI remain shared: + +```text +src/ + core/ game rules, AI, statistics, training, replays + app.js shared application UI and workflows + styles.css shared responsive visual system + i18n.js Russian, English, and Simplified Chinese + assets/ shared images and audio + platform/ + index.js runtime selection + web.js browser capabilities + native.js Capacitor capabilities + telegram.js Telegram Mini App capabilities + auth/ + telegram-mini-app.js Mini App auth client and bootstrap + index.html website and Capacitor shell + telegram/ + index.html Telegram SDK shell +``` + +The platform selection order is explicit: + +1. a Capacitor native runtime selects `native`; +2. the Telegram shell with an initialized Telegram WebApp SDK selects `telegram`; +3. every other context selects `web`. + +The Telegram shell opened outside Telegram does not silently fall back to the +normal website. It renders a localized explanation and an explicit command to +open the Main Mini App in Telegram. + +Game modules do not import Telegram or Capacitor APIs. The Telegram adapter does +not import game rules or mutate board state. Shared UI code communicates through +the existing platform boundary, extended with runtime-neutral capabilities only +where the current interface cannot express Telegram behavior. + +## Build Outputs + +One `npm run build` produces: + +```text +dist/ + index.html + telegram/index.html + app..js + styles..css + assets/ +``` + +Both HTML shells reference the same application and stylesheet hashes. The +Telegram shell additionally loads the official `telegram-web-app.js` SDK before +the application bootstrap and marks the requested runtime as Telegram. + +The deployment model is: + +- GitHub Pages publishes all of `dist`; +- the website opens `dist/index.html`; +- the Mini App opens `dist/telegram/index.html`; +- Capacitor packages the root application from `dist` for Android and iOS. + +Pages and the Mini App update as soon as the selected commit is deployed. +Android and iOS contain the same source revision but update only through store +releases. Every output exposes the source commit or build identifier in settings +so deployed versions can be diagnosed accurately. + +## Telegram Platform Adapter + +The adapter maps Telegram functionality to the shared platform contract: + +- network status uses browser online/offline events; +- sharing opens Telegram-native invite or replay flows, with copy fallback; +- haptic events map to `Telegram.WebApp.HapticFeedback`; +- back navigation maps to `Telegram.WebApp.BackButton`; +- lifecycle maps to `activated` and `deactivated` events; +- settings entry maps to `Telegram.WebApp.SettingsButton`; +- viewport and safe-area events update CSS runtime variables; +- external Telegram links use `openTelegramLink`; +- ordinary external links use `openLink`; +- application preferences retain the current shared settings abstraction. + +Unsupported Telegram features degrade without blocking local play. Capability +checks use the reported Telegram WebApp version before invoking newer APIs. + +The adapter calls `ready()` when the essential first screen is rendered, +expands the Mini App, and requests fullscreen on supported clients. A rejected +or unsupported fullscreen request keeps the responsive non-fullscreen layout. + +## Authentication + +The Mini App does not show a Telegram login button. It sends the raw string from +`Telegram.WebApp.initData` to a dedicated Worker endpoint: + +```http +POST /auth/telegram/miniapp +Content-Type: application/json + +{"initData":"query_id=...&user=...&auth_date=...&hash=..."} +``` + +The Worker performs these steps: + +1. enforce method, content type, request size, and an exact JSON shape; +2. parse the query string without collapsing duplicate fields; +3. reject missing, duplicated, malformed, or unsupported fields; +4. construct the alphabetical data-check string defined by Telegram; +5. derive the `WebAppData` HMAC key from `TELEGRAM_BOT_TOKEN`; +6. compare the supplied and expected hashes in constant time; +7. reject an `auth_date` older than five minutes or more than sixty seconds in + the future; +8. validate and normalize the Telegram user object; +9. create a regular Salvo session through the existing session service; +10. return the existing `{ token, user }` response shape. + +Raw `initData` is never logged or persisted. The returned Salvo session token is +held in memory by the Mini App and is replaced by a fresh automatic exchange on +the next launch. A page reload can exchange the still-fresh launch data again; +expired launch data requires reopening the Mini App. + +The normalized user key remains `telegram:`. A player therefore sees the +same account whether authentication originated from web OIDC, native OIDC, the +legacy login flow, or Mini App launch data. No account migration or duplicate +profile is created. + +`initDataUnsafe` may be used only for non-authoritative presentation before the +server response. It never grants access to a profile, room, leaderboard action, +or replay. + +## Mini App Information Architecture + +### Startup and home + +The Mini App opens at full available height and shows the shared game hub. After +authentication, the header displays the verified Telegram name and avatar. The +login command is absent. A failed online bootstrap leaves agent, training, and +same-device play available. + +The verified Telegram language is used as the initial language when the player +has not already selected one. A user preference continues to override the +Telegram language. + +### Setup and battle + +Fleet setup and battle use the same mobile-first layouts as the web and native +clients. On phones, a single board occupies the available width and the Target, +Fleet, and Log controls switch context. Horizontal page or board scrolling is +not permitted. On sufficiently wide Telegram Desktop windows, the shared +two-column tactical layout is used. + +The 16x16 preset also fits the available width. Its target interaction provides +a prominent focus marker and coordinate feedback so smaller cells remain +selectable without horizontal scrolling. + +Telegram safe-area and content-safe-area values are reflected in CSS custom +properties. Stable viewport height is used for bottom positioning; the animated +viewport height is not used to pin controls during resize gestures. + +### Navigation and closing + +Telegram BackButton follows the shared navigation hierarchy: + +1. close the active dialog; +2. close settings, profile, leaderboard, coaching, or another overlay; +3. leave replay or setup for its parent screen; +4. request confirmation before abandoning an unfinished battle; +5. hide the BackButton on the home screen. + +Telegram closing confirmation is enabled only while an unfinished battle can be +lost. It is disabled after a battle ends or the user returns to the home screen. + +### Lifecycle, theme, audio, and haptics + +`deactivated` pauses music and transient visual effects. `activated` resumes +only the audio appropriate for the current screen and the user's sound setting. + +Telegram's light or dark scheme supplies the initial theme. The existing game +theme remains user-selectable and readable in both schemes. Theme and viewport +changes are processed without reloading the game. + +Shared haptic events map to Telegram feedback: + +- placement: light impact; +- hit: medium impact; +- sunk: heavy impact; +- invalid placement: warning notification; +- victory: success notification; +- defeat: error notification. + +Gameplay never depends on haptic availability. + +## Rooms, Replays, and Sharing + +The bot is configured as a Main Mini App. The canonical Telegram launch forms +are: + +```text +https://t.me/agents_salvo_bot?startapp +https://t.me/agents_salvo_bot?startapp=room_ABCD +https://t.me/agents_salvo_bot?startapp=replay_ +``` + +Launch parameters are parsed by a strict shared module. Room values match +`room_[A-Z0-9]{4,12}`. Replay values consist of the `replay_` prefix followed by +the existing `[A-Za-z0-9-]{1,128}` replay identifier. An invalid value opens the +home screen and does not become a general-purpose internal route. + +A room invitation follows this flow: + +1. an authenticated captain creates an online room; +2. the Mini App builds the canonical `startapp=room_` link; +3. the player chooses a Telegram chat through a native share flow; +4. the recipient opens the Main Mini App and authenticates automatically; +5. the app opens the online lobby and joins the referenced room; +6. normal server-authoritative room behavior continues unchanged. + +Replay links open the replay screen after authentication and the existing replay +authorization check. Failure to access an absent or private replay produces the +existing recoverable replay error. + +The first release uses `openTelegramLink` with Telegram's `t.me/share/url` flow +and falls back to copying the canonical launch link. Prepared inline messages +and bot-authored rich result cards remain a later enhancement. + +## Offline and Failure Behavior + +- Missing Telegram SDK or launch data on the Telegram route shows an open-in- + Telegram state rather than a misleading login failure. +- Invalid or expired `initData` tells the user to reopen the Mini App. +- Worker failure leaves local modes available and gives online features a retry + action without clearing local progress. +- A full, missing, or closed room returns the user to the online lobby with a + localized explanation. +- Invalid launch parameters are ignored safely and open the home screen. +- Fullscreen, haptics, settings button, and native sharing are optional runtime + capabilities with documented fallbacks. +- Deactivation or viewport changes never discard fleet setup or active local + battle state. + +## BotFather Configuration + +After the Worker endpoint and Pages route are deployed: + +1. open `@agents_salvo_bot` in BotFather; +2. enable the Main Mini App; +3. set `https://agent-axiom.github.io/agents-salvo/telegram/` as its URL; +4. request `salvo` as the short name and use `agents_salvo` if it is unavailable; +5. configure the bot menu command as the localized equivalent of `Play`; +6. upload Russian, English, and Chinese descriptions, screenshots, and previews; +7. set loading-screen colors and icon to match the Salvo identity. + +The Mini App URL remains HTTPS and contains no credentials or environment +secrets. The existing website Login Widget and OIDC configuration remain +independent of Main Mini App configuration. + +## Privacy and Security + +- Trust only server-validated Telegram launch data. +- Keep `TELEGRAM_BOT_TOKEN` exclusively in Worker secrets. +- Compare authentication hashes in constant time. +- Enforce a short launch-data lifetime through `auth_date`. +- Reject duplicate query keys instead of accepting ambiguous input. +- Bound request and field sizes before parsing nested JSON. +- Never put launch data, authorization codes, or Salvo session tokens in URLs. +- Never log raw launch data or session tokens. +- Continue enforcing authorization in Durable Objects and D1-backed profile and + replay endpoints; client runtime detection grants no server permission. +- Update the privacy notice to describe Mini App launch data and its purpose. + +## Testing and CI + +Automated coverage includes: + +- runtime selection for web, Capacitor, Telegram, and Telegram-shell fallback; +- adapter behavior with a deterministic fake `Telegram.WebApp`; +- BackButton, settings, fullscreen, lifecycle, theme, viewport, safe area, + external links, sharing, and haptic mappings; +- automatic auth bootstrap and absence of a login command in Mini App mode; +- room and replay launch-parameter parsing; +- exact identity compatibility with existing `telegram:` profiles; +- valid Mini App signature verification; +- tampered signature, wrong token, stale or future `auth_date`, duplicate keys, + malformed user JSON, missing fields, and oversized request rejection; +- local-mode fallback when automatic authentication or the Worker fails; +- build assertions that both shells reference the same JS and CSS artifacts. + +The existing test and coverage gates remain active. CI additionally verifies the +web and Telegram output before Capacitor synchronization: + +```text +tests -> coverage -> shared web/telegram build -> Capacitor sync + -> Android checks -> iOS checks +``` + +The pre-release manual matrix covers Telegram Android, iOS, and Desktop; all +three languages; light and dark themes; every game mode; 8x8, 10x10, and 16x16 +boards; room invitations; replay links; backgrounding and returning to battle; +and fallback behavior on clients without newer fullscreen APIs. + +## Rollout Order + +1. Add the Worker Mini App auth endpoint and cryptographic tests. +2. Add the Telegram platform adapter and deterministic adapter tests. +3. Produce the Telegram shell from the shared build. +4. Add automatic auth bootstrap and account-state UI behavior. +5. Integrate fullscreen, BackButton, settings, safe areas, lifecycle, and haptics. +6. Add strict room and replay launch handling plus Telegram sharing. +7. Extend CI and complete the manual compatibility matrix. +8. Deploy the Worker and GitHub Pages outputs. +9. Enable and publish the Main Mini App through BotFather. + +## Success Criteria + +- A change to shared game rules or UI reaches web, Telegram, Android, and iOS + from the same source revision without copying code. +- A Telegram user enters the Mini App without a separate login interaction and + sees the same profile and history used by other Salvo clients. +- Every current mode and preset is playable inside Telegram. +- Phone battle boards fit the available width without horizontal scrolling. +- Room invite links open and join the intended authenticated online lobby. +- Invalid or expired Telegram data cannot create a Salvo session. +- Existing website and native authentication continue to work unchanged. + +## References + +- [Telegram Mini Apps](https://core.telegram.org/bots/webapps) +- [Validating Mini App data](https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app) diff --git a/package.json b/package.json index 83b4fb9..7d83269 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,12 @@ "type": "module", "scripts": { "test": "node --test tests/*.test.mjs", - "coverage": "npm run coverage:core && npm run coverage:app", + "coverage": "npm run coverage:core && npm run coverage:app && npm run coverage:critical:build-publication && npm run coverage:critical:build && npm run coverage:critical:worker", "coverage:core": "SALVO_APP_CHILD_COVERAGE=isolated node --experimental-test-coverage --test-coverage-lines=98 --test tests/*.test.mjs", "coverage:app": "SALVO_APP_CHILD_COVERAGE=inherit node --experimental-test-coverage --test-coverage-include=src/app.js --test-coverage-lines=39 --test tests/app-behavior.test.mjs", + "coverage:critical:build-publication": "SALVO_APP_CHILD_COVERAGE=isolated node --experimental-test-coverage --test-coverage-include=scripts/build-publication.mjs --test-coverage-lines=98 --test tests/telegram-build.test.mjs", + "coverage:critical:build": "SALVO_APP_CHILD_COVERAGE=isolated node --experimental-test-coverage --test-coverage-include=scripts/build.mjs --test-coverage-lines=98 --test tests/telegram-build.test.mjs", + "coverage:critical:worker": "node --experimental-test-coverage --test-coverage-include=worker/index.js --test-coverage-lines=98 --test tests/worker.test.mjs tests/profile.test.mjs tests/telegram-mini-app-worker.test.mjs tests/telegram-oidc-worker.test.mjs", "build": "node scripts/build.mjs", "start": "python3 -m http.server 5173 -d dist", "mobile:sync": "npm run build && cap sync", diff --git a/scripts/build-publication.mjs b/scripts/build-publication.mjs new file mode 100644 index 0000000..8717945 --- /dev/null +++ b/scripts/build-publication.mjs @@ -0,0 +1,938 @@ +import { randomUUID } from "node:crypto"; +import { + lstat, + mkdir, + open, + readFile, + readdir, + rename, + rm, +} from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const DEFAULT_LOCK_RETRY_MS = 25; +const DEFAULT_LOCK_STALE_MS = 5 * 60 * 1000; +const DEFAULT_LOCK_TIMEOUT_MS = 10_000; + +export function buildStatePaths(output) { + // build.mjs canonicalizes the destination before state siblings are derived. + const destinationPath = resolve(output); + const parentPath = dirname(destinationPath); + const name = basename(destinationPath); + const lockPath = anchoredStatePath( + parentPath, + resolve(parentPath, `.${name}.lock`), + "Build lock path", + ); + return { + backupPath: anchoredStatePath( + parentPath, + resolve(parentPath, `.${name}.backup`), + "Build backup path", + ), + destinationPath, + lockOwnerPath: anchoredStatePath( + parentPath, + join(lockPath, "owner.json"), + "Build lock owner path", + ), + lockPath, + lockCandidatePrefix: anchoredStatePath( + parentPath, + `${lockPath}.candidate-`, + "Build lock candidate path", + ), + lockRecoveryPath: anchoredStatePath( + parentPath, + join(lockPath, ".recovery"), + "Build lock recovery path", + ), + lockRecoveryCandidatePrefix: anchoredStatePath( + parentPath, + `${lockPath}.recovery-candidate-`, + "Build recovery candidate path", + ), + lockRecoveryQuarantinePrefix: anchoredStatePath( + parentPath, + `${lockPath}.recovery-quarantine-`, + "Build recovery quarantine path", + ), + parentPath, + stagePrefix: anchoredStatePath( + parentPath, + resolve(parentPath, `.${name}.stage-`), + "Build stage path", + ), + staleLockPrefix: anchoredStatePath( + parentPath, + resolve(parentPath, `.${name}.lock.stale-`), + "Build stale lock path", + ), + }; +} + +export async function acquireBuildLock( + output, + { + retryMs = DEFAULT_LOCK_RETRY_MS, + staleMs = DEFAULT_LOCK_STALE_MS, + timeoutMs = DEFAULT_LOCK_TIMEOUT_MS, + onCandidateReady, + onRecoveryCandidateReady, + onRecoveryClaimPublished, + } = {}, +) { + if (onCandidateReady !== undefined && typeof onCandidateReady !== "function") { + throw new TypeError("onCandidateReady must be a function when provided."); + } + if ( + onRecoveryCandidateReady !== undefined + && typeof onRecoveryCandidateReady !== "function" + ) { + throw new TypeError( + "onRecoveryCandidateReady must be a function when provided.", + ); + } + if ( + onRecoveryClaimPublished !== undefined + && typeof onRecoveryClaimPublished !== "function" + ) { + throw new TypeError( + "onRecoveryClaimPublished must be a function when provided.", + ); + } + const paths = buildStatePaths(output); + const deadline = Date.now() + timeoutMs; + while (true) { + const inspection = await inspectLock(paths.lockPath, staleMs); + if (inspection.exists) { + await waitForExistingLock(paths, inspection, { + deadline, + onRecoveryCandidateReady, + onRecoveryClaimPublished, + retryMs, + staleMs, + }); + continue; + } + + const candidate = await prepareLockCandidate(paths); + let candidateOwned = true; + const removeCandidate = async () => { + if (candidateOwned) { + await removeOwnedCandidate(candidate); + candidateOwned = false; + } + }; + try { + await onCandidateReady?.({ + candidatePath: candidate.path, + owner: candidate.owner, + ownerPath: candidate.ownerPath, + }); + + const current = await inspectLock(paths.lockPath, staleMs); + if (current.exists) { + await removeCandidate(); + await waitForExistingLock(paths, current, { + deadline, + onRecoveryCandidateReady, + onRecoveryClaimPublished, + retryMs, + staleMs, + }); + continue; + } + + try { + await rename(candidate.path, paths.lockPath); + candidateOwned = false; + return { owner: candidate.owner, path: paths.lockPath }; + } catch (error) { + await removeCandidate(); + if (!isLockContentionError(error)) { + throw error; + } + const winner = await inspectLock(paths.lockPath, staleMs); + if (!winner.exists) { + throw error; + } + await waitForExistingLock(paths, winner, { + deadline, + onRecoveryCandidateReady, + onRecoveryClaimPublished, + retryMs, + staleMs, + }); + } + } finally { + await removeCandidate(); + } + } +} + +export async function releaseBuildLock(lock) { + const owner = await readLockOwner(lock.path); + if (!sameOwner(owner, lock.owner)) { + throw new Error(`Build lock ownership changed before release: ${lock.path}.`); + } + await rm(lock.path, { recursive: true, force: true }); +} + +export async function reconcileBuildState(output, lock) { + const paths = buildStatePaths(output); + await assertLockOwnership(lock, paths.lockPath); + const destinationExists = await pathExists(paths.destinationPath); + const backup = await inspectRealDirectory( + paths.backupPath, + "Build backup path", + { allowMissing: true }, + ); + + if (!destinationExists && backup) { + await rename(paths.backupPath, paths.destinationPath); + } else if (destinationExists && backup) { + await rm(paths.backupPath, { recursive: true, force: true }); + } + + const entries = await readdir(paths.parentPath); + const generatedStates = [ + [basename(paths.stagePrefix), "Build stage path"], + [basename(paths.staleLockPrefix), "Build stale lock path"], + [ + basename(paths.lockRecoveryQuarantinePrefix), + "Build recovery quarantine path", + ], + ]; + for (const entry of entries) { + const generatedState = generatedStates.find(([prefix]) => + entry.startsWith(prefix), + ); + if (!generatedState) { + continue; + } + const [, label] = generatedState; + const statePath = anchoredStatePath( + paths.parentPath, + resolve(paths.parentPath, entry), + label, + ); + await inspectRealDirectory(statePath, label); + await rm(statePath, { recursive: true, force: true }); + } +} + +export async function publishBuild( + stage, + output, + { renamePath = rename, removePath = rm } = {}, +) { + const paths = buildStatePaths(output); + const stagePath = assertStagePath(paths, stage); + await inspectRealDirectory(stagePath, "Build stage path"); + await inspectRealDirectory(paths.backupPath, "Build backup path", { + allowMissing: true, + }); + let hasPrevious = false; + try { + await renamePath(paths.destinationPath, paths.backupPath); + hasPrevious = true; + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } + + try { + await renamePath(stagePath, paths.destinationPath); + } catch (publishError) { + if (hasPrevious) { + try { + await renamePath(paths.backupPath, paths.destinationPath); + } catch (restoreError) { + throw new AggregateError( + [publishError, restoreError], + `Build publication failed; previous output remains at ${paths.backupPath}.`, + ); + } + } + throw publishError; + } + + if (hasPrevious) { + await removePath(paths.backupPath, { recursive: true, force: true }); + } +} + +async function prepareLockCandidate(paths) { + return prepareOwnedCandidate(paths, { + candidatePrefix: paths.lockCandidatePrefix, + directoryLabel: "Build lock candidate path", + ownerLabel: "Build lock candidate owner path", + }); +} + +async function prepareRecoveryCandidate(paths) { + return prepareOwnedCandidate(paths, { + candidatePrefix: paths.lockRecoveryCandidatePrefix, + directoryLabel: "Build recovery candidate path", + ownerLabel: "Build recovery candidate owner path", + }); +} + +async function prepareOwnedCandidate( + paths, + { candidatePrefix, directoryLabel, ownerLabel }, +) { + while (true) { + const owner = { + pid: process.pid, + timestamp: Date.now(), + token: randomUUID(), + }; + const candidatePath = anchoredStatePath( + paths.parentPath, + `${candidatePrefix}${owner.token}`, + directoryLabel, + ); + try { + await mkdir(candidatePath); + } catch (error) { + if (error.code === "EEXIST") { + continue; + } + throw error; + } + + const stats = await inspectRealDirectory( + candidatePath, + directoryLabel, + ); + const ownerPath = anchoredStatePath( + paths.parentPath, + join(candidatePath, "owner.json"), + ownerLabel, + ); + const candidate = { + device: stats.dev, + directoryLabel, + inode: stats.ino, + owner, + ownerLabel, + ownerPath, + path: candidatePath, + }; + try { + await writeDurableOwner(candidate); + return candidate; + } catch (error) { + await removeCandidateByIdentity(candidate); + throw error; + } + } +} + +async function writeDurableOwner(candidate) { + await inspectRealDirectory(candidate.path, candidate.directoryLabel); + let handle; + try { + handle = await open(candidate.ownerPath, "wx", 0o600); + await handle.writeFile(`${JSON.stringify(candidate.owner)}\n`, "utf8"); + await handle.sync(); + } finally { + if (handle) { + await handle.close(); + } + } + await inspectRealDirectory(candidate.path, candidate.directoryLabel); + await inspectRegularFile(candidate.ownerPath, candidate.ownerLabel); +} + +async function removeOwnedCandidate(candidate) { + const stats = await inspectRealDirectory( + candidate.path, + candidate.directoryLabel, + { allowMissing: true }, + ); + if (!stats) { + return; + } + if (stats.dev !== candidate.device || stats.ino !== candidate.inode) { + throw new Error( + `Build lock candidate ownership changed before cleanup: ${candidate.path}.`, + ); + } + const owner = await readLockOwner(candidate.path, { + directoryLabel: candidate.directoryLabel, + ownerLabel: candidate.ownerLabel, + }); + if (!sameOwner(owner, candidate.owner)) { + throw new Error( + `Build lock candidate ownership changed before cleanup: ${candidate.path}.`, + ); + } + await rm(candidate.path, { recursive: true, force: true }); +} + +async function removeCandidateByIdentity(candidate) { + const stats = await inspectRealDirectory( + candidate.path, + candidate.directoryLabel, + { allowMissing: true }, + ); + if (!stats) { + return; + } + if (stats.dev !== candidate.device || stats.ino !== candidate.inode) { + throw new Error( + `Build lock candidate ownership changed before cleanup: ${candidate.path}.`, + ); + } + await rm(candidate.path, { recursive: true, force: true }); +} + +async function assertLockOwnership(lock, expectedPath = lock.path) { + if (resolve(lock.path) !== resolve(expectedPath)) { + throw new Error(`Build lock is not owned by this process: ${lock.path}.`); + } + const owner = await readLockOwner(lock.path); + if (!sameOwner(owner, lock.owner)) { + throw new Error(`Build lock is not owned by this process: ${lock.path}.`); + } +} + +async function inspectLock(lockPath, staleMs) { + const lockStats = await inspectRealDirectory(lockPath, "Build lock path", { + allowMissing: true, + }); + if (!lockStats) { + return { exists: false, recoverable: false }; + } + + const owner = await readLockOwner(lockPath); + if (owner) { + return { + device: lockStats.dev, + exists: true, + inode: lockStats.ino, + owner, + recoverable: !processIsAlive(owner.pid), + }; + } + return { + device: lockStats.dev, + exists: true, + inode: lockStats.ino, + owner: null, + recoverable: Date.now() - lockStats.mtimeMs >= staleMs, + }; +} + +async function waitForExistingLock( + paths, + inspection, + { + deadline, + onRecoveryCandidateReady, + onRecoveryClaimPublished, + retryMs, + staleMs, + }, +) { + if (inspection.recoverable) { + const recovered = await recoverStaleLock(paths, inspection, { + onRecoveryCandidateReady, + onRecoveryClaimPublished, + staleMs, + }); + if (recovered) { + return; + } + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for build lock ${paths.lockPath}.`); + } + await delay(retryMs); +} + +function isLockContentionError(error) { + return ["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code); +} + +async function acquireRecoveryClaim( + paths, + inspection, + { + onRecoveryCandidateReady, + onRecoveryClaimPublished, + staleMs, + }, +) { + const lockStats = await inspectRealDirectory( + paths.lockPath, + "Build lock path", + { allowMissing: true }, + ); + if (!lockStats) { + return false; + } + if ( + lockStats.dev !== inspection.device + || lockStats.ino !== inspection.inode + ) { + return false; + } + const existingRecovery = await inspectRecoveryClaim(paths, staleMs); + if (existingRecovery.exists) { + if (!existingRecovery.recoverable) { + return null; + } + const removed = await removeAbandonedRecoveryClaim( + paths, + existingRecovery, + staleMs, + ); + if (!removed) { + return null; + } + } + + const candidate = await prepareRecoveryCandidate(paths); + let candidateOwned = true; + const removeCandidate = async () => { + if (candidateOwned) { + await removeOwnedCandidate(candidate); + candidateOwned = false; + } + }; + try { + await onRecoveryCandidateReady?.({ + candidatePath: candidate.path, + owner: candidate.owner, + ownerPath: candidate.ownerPath, + }); + + const currentStats = await inspectRealDirectory( + paths.lockPath, + "Build lock path", + { allowMissing: true }, + ); + if ( + !currentStats + || currentStats.dev !== inspection.device + || currentStats.ino !== inspection.inode + ) { + return null; + } + const currentRecovery = await inspectRecoveryClaim(paths, staleMs); + if (currentRecovery.exists) { + return null; + } + + try { + await rename(candidate.path, paths.lockRecoveryPath); + candidateOwned = false; + } catch (error) { + await removeCandidate(); + if (error.code === "ENOENT") { + return null; + } + if (!isLockContentionError(error)) { + throw error; + } + const winner = await inspectRecoveryClaim(paths, staleMs); + if (!winner.exists) { + throw error; + } + return null; + } + + const claim = { + ...candidate, + ownerPath: anchoredStatePath( + paths.parentPath, + join(paths.lockRecoveryPath, "owner.json"), + "Build recovery claim owner path", + ), + path: paths.lockRecoveryPath, + }; + await onRecoveryClaimPublished?.({ + claimPath: claim.path, + owner: claim.owner, + ownerPath: claim.ownerPath, + }); + const publishedLock = await inspectRealDirectory( + paths.lockPath, + "Build lock path", + { allowMissing: true }, + ); + if ( + !publishedLock + || publishedLock.dev !== inspection.device + || publishedLock.ino !== inspection.inode + ) { + await releaseRecoveryClaim(claim); + return null; + } + return claim; + } finally { + await removeCandidate(); + } +} + +async function inspectRecoveryClaim( + paths, + staleMs, + { + directoryLabel = "Build lock recovery path", + ownerLabel = "Build recovery claim owner path", + path = paths.lockRecoveryPath, + } = {}, +) { + const stats = await inspectRealDirectory(path, directoryLabel, { + allowMissing: true, + }); + if (!stats) { + return { exists: false, recoverable: false }; + } + const owner = await readLockOwner(path, { + directoryLabel, + ownerLabel, + }); + return { + device: stats.dev, + exists: true, + inode: stats.ino, + mtimeMs: stats.mtimeMs, + owner, + recoverable: owner + ? !processIsAlive(owner.pid) + : Date.now() - stats.mtimeMs >= staleMs, + }; +} + +async function removeAbandonedRecoveryClaim(paths, inspection, staleMs) { + let quarantinePath; + while (true) { + quarantinePath = anchoredStatePath( + paths.parentPath, + `${paths.lockRecoveryQuarantinePrefix}${randomUUID()}`, + "Build recovery quarantine path", + ); + try { + await rename(paths.lockRecoveryPath, quarantinePath); + break; + } catch (error) { + if (error.code === "ENOENT") { + return false; + } + if (error.code === "EEXIST") { + continue; + } + throw error; + } + } + + const quarantined = await inspectRecoveryClaim(paths, staleMs, { + directoryLabel: "Build recovery quarantine path", + ownerLabel: "Build recovery quarantine owner path", + path: quarantinePath, + }); + if ( + !sameDirectoryIdentity(quarantined, inspection) + || !sameInspectedOwner(quarantined.owner, inspection.owner) + || !quarantined.recoverable + ) { + await restoreRecoveryClaim(paths, quarantinePath); + return false; + } + + await rm(quarantinePath, { recursive: true, force: true }); + return true; +} + +async function restoreRecoveryClaim(paths, quarantinePath) { + const current = await inspectRealDirectory( + paths.lockRecoveryPath, + "Build lock recovery path", + { allowMissing: true }, + ); + if (current) { + return false; + } + try { + await rename(quarantinePath, paths.lockRecoveryPath); + return true; + } catch (error) { + if (error.code === "ENOENT" || isLockContentionError(error)) { + return false; + } + throw error; + } +} + +function sameDirectoryIdentity(first, second) { + return ( + (first.device === undefined + || second.device === undefined + || first.device === second.device) + && (first.inode === undefined + || second.inode === undefined + || first.inode === second.inode) + ); +} + +function sameInspectedOwner(first, second) { + return second ? sameOwner(first, second) : first === null; +} + +async function releaseRecoveryClaim(claim) { + const stats = await inspectRealDirectory( + claim.path, + "Build recovery claim path", + { allowMissing: true }, + ); + if (!stats) { + return; + } + const owner = await readLockOwner(claim.path, { + directoryLabel: "Build recovery claim path", + ownerLabel: "Build recovery claim owner path", + }); + if ( + stats.dev !== claim.device + || stats.ino !== claim.inode + || !sameOwner(owner, claim.owner) + ) { + throw new Error( + `Build recovery claim ownership changed before release: ${claim.path}.`, + ); + } + await rm(claim.path, { recursive: true, force: true }); +} + +async function recoverStaleLock( + paths, + inspection, + { + onRecoveryCandidateReady, + onRecoveryClaimPublished, + staleMs, + }, +) { + const claim = await acquireRecoveryClaim(paths, inspection, { + onRecoveryCandidateReady, + onRecoveryClaimPublished, + staleMs, + }); + if (!claim) { + return false; + } + let quarantined = false; + try { + const currentStats = await inspectRealDirectory( + paths.lockPath, + "Build lock path", + ); + if ( + currentStats.dev !== inspection.device + || currentStats.ino !== inspection.inode + ) { + return false; + } + const currentOwner = await readLockOwner(paths.lockPath); + if (inspection.owner) { + if ( + !sameOwner(currentOwner, inspection.owner) + || processIsAlive(currentOwner.pid) + ) { + return false; + } + } else if (currentOwner) { + return false; + } + + const quarantinePath = anchoredStatePath( + paths.parentPath, + `${paths.staleLockPrefix}${randomUUID()}`, + "Build stale lock path", + ); + await rename(paths.lockPath, quarantinePath); + quarantined = true; + await inspectRealDirectory(quarantinePath, "Build stale lock path"); + await rm(quarantinePath, { recursive: true, force: true }); + return true; + } catch (error) { + if (error.code === "ENOENT") { + return false; + } + throw error; + } finally { + if (!quarantined) { + await releaseRecoveryClaim(claim); + } + } +} + +async function readLockOwner( + lockPath, + { + directoryLabel = "Build lock path", + ownerLabel = "Build lock owner path", + } = {}, +) { + const parentPath = dirname(resolve(lockPath)); + const ownerPath = anchoredStatePath( + parentPath, + join(lockPath, "owner.json"), + ownerLabel, + ); + const lock = await inspectRealDirectory(lockPath, directoryLabel, { + allowMissing: true, + }); + if (!lock) { + return null; + } + const ownerStats = await inspectRegularFile( + ownerPath, + ownerLabel, + { allowMissing: true }, + ); + if (!ownerStats) { + return null; + } + await inspectRealDirectory(lockPath, directoryLabel); + try { + const owner = JSON.parse(await readFile(ownerPath, "utf8")); + if ( + Number.isInteger(owner.pid) + && owner.pid > 0 + && Number.isFinite(owner.timestamp) + && typeof owner.token === "string" + && owner.token.length > 0 + ) { + return owner; + } + } catch (error) { + if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) { + throw error; + } + } + return null; +} + +function anchoredStatePath(parentPath, candidate, label) { + const canonicalParent = resolve(parentPath); + const statePath = resolve(candidate); + const childPath = relative(canonicalParent, statePath); + if ( + !childPath + || childPath === ".." + || childPath.startsWith(`..${sep}`) + || isAbsolute(childPath) + ) { + throw new Error( + `${label} must remain under the canonical build destination parent.`, + ); + } + return statePath; +} + +function assertStagePath(paths, stage) { + const stagePath = anchoredStatePath( + paths.parentPath, + stage, + "Build stage path", + ); + const stageNamePrefix = basename(paths.stagePrefix); + if ( + dirname(stagePath) !== paths.parentPath + || !basename(stagePath).startsWith(stageNamePrefix) + || basename(stagePath) === stageNamePrefix + ) { + throw new Error( + "Build stage path must be a generated sibling of the destination.", + ); + } + return stagePath; +} + +async function inspectRealDirectory( + path, + label, + { allowMissing = false } = {}, +) { + let stats; + try { + stats = await lstat(path); + } catch (error) { + if (allowMissing && error.code === "ENOENT") { + return null; + } + throw error; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`${label} must be a real directory: ${path}.`); + } + return stats; +} + +async function inspectRegularFile( + path, + label, + { allowMissing = false } = {}, +) { + let stats; + try { + stats = await lstat(path); + } catch (error) { + if (allowMissing && error.code === "ENOENT") { + return null; + } + throw error; + } + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`${label} must be a regular file: ${path}.`); + } + return stats; +} + +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code !== "ESRCH"; + } +} + +function sameOwner(first, second) { + return Boolean( + first + && second + && first.pid === second.pid + && first.timestamp === second.timestamp + && first.token === second.token, + ); +} + +async function pathExists(path) { + try { + await lstat(path); + return true; + } catch (error) { + if (error.code === "ENOENT") { + return false; + } + throw error; + } +} diff --git a/scripts/build.mjs b/scripts/build.mjs index dd5cb22..fe5881c 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,18 +1,50 @@ -import { cp, mkdir, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { + cp, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; -import { isAbsolute, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { build } from "esbuild"; +import { + acquireBuildLock, + publishBuild, + reconcileBuildState, + releaseBuildLock, +} from "./build-publication.mjs"; const root = resolve(import.meta.dirname, ".."); const src = resolve(root, "src"); -const dist = resolveBuildOutput(); +const buildId = resolveBuildId(); +const destination = await resolveBuildOutput(); -function resolveBuildOutput() { +const shellReplacements = [ + { + path: "index.html", + assetPrefix: "./", + styleReference: '', + appReference: '', + }, + { + path: "telegram/index.html", + assetPrefix: "../", + styleReference: '', + appReference: '', + }, +]; + +async function resolveBuildOutput() { if (!process.env.SALVO_BUILD_DIR) { return resolve(root, "dist"); } - const candidate = resolve(process.env.SALVO_BUILD_DIR); - const temporaryRoot = resolve(tmpdir()); + const candidate = await canonicalizePath(resolve(process.env.SALVO_BUILD_DIR)); + const temporaryRoot = await realpath(resolve(tmpdir())); const temporaryPath = relative(temporaryRoot, candidate); if ( !temporaryPath @@ -25,19 +57,141 @@ function resolveBuildOutput() { return candidate; } -await rm(dist, { recursive: true, force: true }); -await mkdir(dist, { recursive: true }); -await cp(src, dist, { recursive: true }); -await build({ - entryPoints: [resolve(src, "app.js")], - outfile: resolve(dist, "app.js"), - bundle: true, - format: "esm", - platform: "browser", - target: ["es2022"], - sourcemap: true, - legalComments: "none", -}); -await writeFile(resolve(dist, ".nojekyll"), ""); - -console.log(`Built ${dist}`); +async function canonicalizePath(candidate) { + const missing = []; + let ancestor = candidate; + while (true) { + try { + return resolve(await realpath(ancestor), ...missing); + } catch (error) { + if (error.code !== "ENOENT" && error.code !== "ENOTDIR") { + throw error; + } + const parent = dirname(ancestor); + if (parent === ancestor) { + throw error; + } + missing.unshift(basename(ancestor)); + ancestor = parent; + } + } +} + +function resolveBuildId() { + if (!Object.hasOwn(process.env, "SALVO_BUILD_ID")) { + return "dev"; + } + const candidate = process.env.SALVO_BUILD_ID; + if (!/^[A-Za-z0-9._-]{1,64}$/.test(candidate)) { + throw new Error( + "SALVO_BUILD_ID must match /^[A-Za-z0-9._-]{1,64}$/.", + ); + } + return candidate; +} + +function hashName(prefix, extension, contents) { + const hash = createHash("sha256").update(contents).digest("hex").slice(0, 10); + return `${prefix}.${hash}.${extension}`; +} + +function replaceExactly(source, expected, replacement, label) { + const first = source.indexOf(expected); + const second = + first === -1 ? -1 : source.indexOf(expected, first + expected.length); + if (first === -1 || second !== -1) { + throw new Error(`${label} must have exactly one occurrence.`); + } + return `${source.slice(0, first)}${replacement}${source.slice( + first + expected.length, + )}`; +} + +async function buildInto(dist) { + await cp(src, dist, { recursive: true }); + await build({ + entryPoints: [resolve(src, "app.js")], + outfile: resolve(dist, "app.js"), + bundle: true, + format: "esm", + platform: "browser", + target: ["es2022"], + sourcemap: true, + legalComments: "none", + }); + + const appPath = resolve(dist, "app.js"); + const sourceMapPath = resolve(dist, "app.js.map"); + const stylePath = resolve(dist, "styles.css"); + const appSource = await readFile(appPath, "utf8"); + const sourceMapSource = await readFile(sourceMapPath); + const styleSource = await readFile(stylePath); + const sourceMapName = hashName("app", "js.map", sourceMapSource); + const rewrittenApp = replaceExactly( + appSource, + "//# sourceMappingURL=app.js.map", + `//# sourceMappingURL=${sourceMapName}`, + "Application sourcemap reference", + ); + const appName = hashName("app", "js", rewrittenApp); + const styleName = hashName("styles", "css", styleSource); + + const rewrittenShells = []; + for (const shell of shellReplacements) { + const shellPath = resolve(dist, shell.path); + let html = await readFile(shellPath, "utf8"); + html = replaceExactly( + html, + shell.styleReference, + ``, + `${shellPath} stylesheet reference`, + ); + html = replaceExactly( + html, + shell.appReference, + ``, + `${shellPath} application reference`, + ); + html = replaceExactly( + html, + 'buildId: "dev"', + `buildId: "${buildId}"`, + `${shellPath} build ID marker`, + ); + rewrittenShells.push([shellPath, html]); + } + + await writeFile(appPath, rewrittenApp); + await rename(appPath, resolve(dist, appName)); + await rename(sourceMapPath, resolve(dist, sourceMapName)); + await rename(stylePath, resolve(dist, styleName)); + for (const [path, html] of rewrittenShells) { + await writeFile(path, html); + } + await writeFile(resolve(dist, ".nojekyll"), ""); +} + +await mkdir(dirname(destination), { recursive: true }); +const lock = await acquireBuildLock(destination); +let stage = null; +try { + // Publication contract: consumers wait for command success. Any interrupted + // rename gap is reconciled under this lock before a new stage is created. + await reconcileBuildState(destination, lock); + stage = await mkdtemp( + resolve(dirname(destination), `.${basename(destination)}.stage-`), + ); + await buildInto(stage); + await publishBuild(stage, destination); + stage = null; +} finally { + try { + if (stage) { + await rm(stage, { recursive: true, force: true }); + } + } finally { + await releaseBuildLock(lock); + } +} + +console.log(`Built ${destination}`); diff --git a/src/app.js b/src/app.js index 25919c5..a691f21 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,5 @@ import { chooseAgentShot } from "./core/ai.js"; +import { assetUrl } from "./asset-url.js"; import { createAudioController } from "./audio.js"; import { createLocalBattleSnapshotStore, @@ -66,6 +67,16 @@ import { createMobileRuntime } from "./mobile.js"; import { platform } from "./platform/index.js"; import { RemoteClient } from "./remote.js"; import { createTelegramAuthClient } from "./telegram-auth.js"; +import { createTelegramMiniAppAuthClient } from "./telegram-mini-app-auth.js"; +import { + parseTelegramStartParam, + telegramMainMiniAppUrl, + telegramReplayUrl, + telegramRoomInviteUrl, +} from "./telegram-launch.js"; + +export { assetUrl }; +export { menuMusicTracks } from "./core/audio.js"; export function bootSalvoApp({ document: appDocument = globalThis.document, @@ -82,6 +93,10 @@ const navigator = appNavigator; const platform = appPlatform; const audio = appAudio; const fetch = appFetch; +const isTelegramMiniApp = platform.getPlatform() === "telegram"; +const telegramUnavailableRoomErrorPattern = + /^room (?:(?:is )?full|(?:(?:is|was) )?not found|(?:(?:is|was) )?closed|(?:(?:is|was) )?unavailable)$/i; +const buildId = validateBuildId(window.SALVO_CONFIG?.buildId); const telegramAuthBootstrap = platform.isNative() ? { type: "none" } : captureTelegramAuthBootstrap({ rawUrl: window.location.href, history: window.history }); @@ -97,9 +112,18 @@ const resultReplayClock = createReplayClock({ }); let telegramWidgetScheduled = false; let platformHydrationRenderScheduled = false; +let hasExplicitThemePreference = false; +let closingConfirmationDesired = null; +let closingConfirmationApplied = null; +let closingConfirmationOperation = null; +let backButtonVisibility = null; +let backButtonVisibilityGeneration = 0; let leaveDialogReturnFocus = null; let pendingLeaveTransition = null; const initialRequestedReplayId = replayIdFromSearch(window.location.search); +let telegramLaunchRouteCaptured = false; +let pendingTelegramLaunchRoute = null; +let telegramLaunchProcessed = false; let authEpoch = 0; let authCallbacksBlocked = false; let activeAuthTicket = null; @@ -194,7 +218,9 @@ const state = { workerUrl: window.SALVO_CONFIG?.workerUrl || "", roomCodeInput: "", status: "", + shareStatus: "", error: "", + errorKey: "", session: null, snapshot: null, client: null, @@ -207,7 +233,17 @@ const state = { }; let telegramAuthClient = null; -if (state.auth.workerUrl) { +let telegramMiniAppClient = null; +if (state.auth.workerUrl && isTelegramMiniApp) { + try { + telegramMiniAppClient = createTelegramMiniAppAuthClient({ + workerUrl: state.auth.workerUrl, + fetcher: fetch, + }); + } catch { + telegramMiniAppClient = null; + } +} else if (state.auth.workerUrl) { try { telegramAuthClient = createTelegramAuthClient({ workerUrl: state.auth.workerUrl, @@ -256,15 +292,24 @@ const mobileRuntime = createMobileRuntime({ onNetwork: handleNetwork, onDeepLink: handlePlatformDeepLink, onBack: handlePlatformBack, + onSettings: handlePlatformSettings, + onThemeChange: handlePlatformThemeChange, pauseAudio: () => audio.pauseForLifecycle(), resumeAudio: () => audio.resumeForLifecycle(state.audioEnabled, state.screen === "menu"), onRuntimeError: reportRuntimeError, }); function getInitialTheme() { + const platformTheme = isTelegramMiniApp ? platform.getTheme?.() : null; + if (["light", "dark"].includes(platformTheme)) return platformTheme; return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } +function validateBuildId(value) { + const buildIdPattern = /^[A-Za-z0-9._-]{1,64}$/; + return typeof value === "string" && buildIdPattern.test(value) ? value : "dev"; +} + function getInitialVisualStyle() { // The legacy first paint used localStorage.getItem("salvo.visualStyle"). return "render"; @@ -284,6 +329,7 @@ function hydratePlatformPreferences() { }), preferenceCoordinator.hydrate("theme", (theme) => { if (["light", "dark"].includes(theme)) { + hasExplicitThemePreference = true; state.theme = theme; schedulePlatformHydrationRender(); } @@ -367,8 +413,9 @@ function applyLocalBattleSnapshot(snapshot) { state.leaderboardOpen = false; state.leaveBattleDialog = false; state.online.roomCodeInput = ""; - state.online.error = ""; + clearOnlineError(); state.online.status = ""; + state.online.shareStatus = ""; state.resultModalDismissed = null; state.resultCopyStatus = ""; resetResultReplayPlayback(); @@ -392,6 +439,19 @@ function handleNetwork(status) { render(); } +function handlePlatformSettings() { + state.settingsOpen = true; + state.profileOpen = false; + state.leaderboardOpen = false; + render(); +} + +function handlePlatformThemeChange(theme) { + if (hasExplicitThemePreference || !["light", "dark"].includes(theme)) return; + state.theme = theme; + render(); +} + function requireOnline(onOffline) { if (hasConfirmedNetworkConnection(state.network)) return true; const message = `${translate("network.offline")} ${translate("network.retry")}`; @@ -405,16 +465,30 @@ function startMobileApp() { startRuntime: () => mobileRuntime.start(), hydratePreferences: hydratePlatformPreferences, hydrateSecureSession, - refreshAuth: telegramAuthBootstrap.type === "ticket" ? async () => {} : refreshAuth, + refreshAuth: isTelegramMiniApp + ? authenticateTelegramMiniApp + : telegramAuthBootstrap.type === "ticket" ? async () => {} : refreshAuth, + processLaunch: isTelegramMiniApp ? processTelegramMiniAppLaunch : undefined, refreshLeaderboard, onError: reportRuntimeError, }); - const capabilityReady = services.runtimeReady.then(loadTelegramAuthCapability); - const bootstrapReady = Promise.all([ - services.runtimeReady, - services.secureSessionReady, - capabilityReady, - ]).then(processTelegramAuthBootstrap); + if (isTelegramMiniApp) { + void services.runtimeReady.then(() => { + if (backButtonVisibility !== true) return; + backButtonVisibility = null; + syncBackButtonVisibility(); + }); + } + const capabilityReady = isTelegramMiniApp + ? services.runtimeReady + : services.runtimeReady.then(loadTelegramAuthCapability); + const bootstrapReady = isTelegramMiniApp + ? services.launchReady + : Promise.all([ + services.runtimeReady, + services.secureSessionReady, + capabilityReady, + ]).then(processTelegramAuthBootstrap); const done = Promise.all([services.done, capabilityReady, bootstrapReady]).then(() => undefined); return { ...services, @@ -424,6 +498,37 @@ function startMobileApp() { }; } +async function processTelegramMiniAppLaunch() { + const route = captureTelegramMiniAppLaunchRoute(); + if ( + telegramLaunchProcessed + || !route + || !state.auth.token + || !state.auth.user + ) return false; + + pendingTelegramLaunchRoute = null; + telegramLaunchProcessed = true; + let replayHistoryMode = "push"; + if (route.type === "replay" && initialRequestedReplayId) { + replayHistoryMode = route.replayId === initialRequestedReplayId ? "none" : "replace"; + } + await navigateToInternalRoute(route, { joinRoom: true, replayHistoryMode }); + return true; +} + +function captureTelegramMiniAppLaunchRoute() { + if (!telegramLaunchRouteCaptured) { + telegramLaunchRouteCaptured = true; + try { + pendingTelegramLaunchRoute = parseTelegramStartParam(platform.getStartParam()); + } catch { + pendingTelegramLaunchRoute = null; + } + } + return pendingTelegramLaunchRoute; +} + async function loadTelegramAuthCapability() { const generation = ++capabilityGeneration; capabilityController?.abort(); @@ -467,6 +572,87 @@ async function processTelegramAuthBootstrap() { return false; } +async function authenticateTelegramMiniApp() { + if (state.auth.token && state.auth.user) { + await processTelegramMiniAppLaunch(); + return true; + } + + let launchData = ""; + let available = false; + try { + available = platform.isAvailable(); + launchData = platform.getLaunchData(); + } catch { + available = false; + } + if (!available || !launchData || !telegramMiniAppClient) { + state.auth.method = "miniapp-unavailable"; + state.auth.error = translate("auth.miniAppOpenInTelegram"); + state.auth.loading = false; + render(); + return false; + } + + state.auth.method = "miniapp"; + if (!requireOnline(() => { + state.auth.error = translate("auth.unavailable"); + })) return false; + + authCallbacksBlocked = false; + const request = captureAuthRequest(); + const controller = beginPrivateRequest("auth"); + state.auth.loading = true; + state.auth.opening = false; + state.auth.error = ""; + render(); + try { + const authPayload = await telegramMiniAppClient.authenticate(launchData, { + signal: controller.signal, + }); + if (!authOperationIsCurrent(request, controller)) return false; + const established = await establishAuthSession( + authPayload.token, + authPayload.user, + () => authOperationIsCurrent(request, controller), + ); + if (!established) return false; + render(); + const sessionRequest = captureAuthRequest(); + await refreshProfile(); + if (authRequestIsCurrent(sessionRequest, currentAuthRequest())) { + const launchProcessed = await processTelegramMiniAppLaunch(); + if (!launchProcessed) await resumeRequestedReplay(); + } + return true; + } catch (error) { + if (isAbortError(error) || !authOperationIsCurrent(request, controller)) { + return false; + } + const expired = error?.status === 401; + state.auth.method = expired ? "miniapp-expired" : "miniapp"; + const errorKey = error?.authKey === "auth.secureStorageFailed" + ? "auth.secureStorageFailed" + : expired + ? "auth.miniAppReopen" + : "auth.unavailable"; + await invalidateAuthSession({ + error: translate(errorKey), + preserveRequestedId: true, + }); + render(); + return false; + } finally { + if (authOperationIsCurrent(request, controller)) { + state.auth.loading = false; + finishPrivateRequest("auth", controller); + render(); + } else { + finishPrivateRequest("auth", controller); + } + } +} + function reportRuntimeError(error) { console.error("Salvo mobile runtime error", error); } @@ -577,6 +763,8 @@ function render() { } else { leaveDialogFocus.deactivate(); } + syncBackButtonVisibility(); + syncClosingConfirmation(); mountTelegramLoginWidget(); syncMenuMusic(); } @@ -761,6 +949,7 @@ function renderSettingsPanel() { ${renderAuthControl()} + Build: ${escapeHtml(buildId)} `; } @@ -818,6 +1007,22 @@ function renderAuthControl() { `; } + if (state.auth.method === "miniapp-unavailable") { + return renderTelegramMiniAppCommand({ + action: "auth-miniapp-open", + errorKey: "auth.miniAppOpenInTelegram", + labelKey: "auth.miniAppOpenCommand", + }); + } + + if (state.auth.method === "miniapp-expired") { + return renderTelegramMiniAppCommand({ + action: "auth-miniapp-reopen", + errorKey: "auth.miniAppReopen", + labelKey: "auth.miniAppReopenCommand", + }); + } + return `
${translate("auth.label")} @@ -828,6 +1033,47 @@ function renderAuthControl() { `; } +function renderTelegramMiniAppCommand({ action, errorKey, labelKey }) { + const command = telegramMiniAppLaunchUrl() + ? `` + : ""; + return ` +
+ ${translate("auth.label")} +

${escapeHtml(translate(errorKey))}

+ ${command} + ${renderTelegramAuthNotices()} +
+ `; +} + +function telegramMiniAppLaunchUrl() { + try { + const route = captureTelegramMiniAppLaunchRoute(); + if (route?.type === "room") { + return telegramRoomInviteUrl(state.auth.telegramBotUsername, route.roomCode); + } + if (route?.type === "replay") { + return telegramReplayUrl(state.auth.telegramBotUsername, route.replayId); + } + return telegramMainMiniAppUrl(state.auth.telegramBotUsername); + } catch { + return ""; + } +} + +async function openTelegramMainMiniApp() { + const url = telegramMiniAppLaunchUrl(); + if (!url) return false; + try { + await platform.openExternalUrl(url); + return true; + } catch (error) { + reportRuntimeError(error); + return false; + } +} + function renderTelegramAuthNotices() { return ` ${translate("auth.valueNotice")} @@ -1113,12 +1359,16 @@ function renderArchivedReplayContent() {
${translate("replayArchive.date")}
${formatReplayDate(replay.finishedAt)}
${translate("replay.timeline")}
${translate("replay.move", { turn: frame.turn, total: frame.totalTurns })}
- +
${ state.replayArchive.copyStatus ? `

${translate( - state.replayArchive.copyStatus === "copied" ? "replayArchive.copied" : "replayArchive.copyFailed", + state.replayArchive.copyStatus === "copied" + ? "replayArchive.copied" + : isTelegramMiniApp ? "share.failed" : "replayArchive.copyFailed", )}

` : "" } @@ -1501,11 +1751,11 @@ function renderLeaderboard(leaderboard) { function menuArtworkSource() { if (state.visualStyle !== "render") { - return "./assets/salvo-board-action.png"; + return assetUrl("./assets/salvo-board-action.png"); } return state.theme === "dark" - ? "./assets/images/backgrounds/main-menu-hero-dark-no-ui.png" - : "./assets/images/backgrounds/main-menu-hero-no-ui.png"; + ? assetUrl("./assets/images/backgrounds/main-menu-hero-dark-no-ui.png") + : assetUrl("./assets/images/backgrounds/main-menu-hero-no-ui.png"); } function renderPresetSelector() { @@ -1879,7 +2129,9 @@ function renderOnlineLobby() {
${translate("mode.online")}

${translate("online.title")}

-

${isOnlineAuthReady() ? translate("online.authReady") : translate("online.authHint")}

+

${isOnlineAuthReady() + ? translate(isTelegramMiniApp ? "auth.miniAppAccountStatus" : "online.authReady") + : translate("online.authHint")}

${translate(`preset.${state.presetId}.name`)}

@@ -1897,7 +2149,8 @@ function renderOnlineLobby() {
- ${state.online.error ? `

${translate("online.error", { message: state.online.error })}

` : ""} + ${renderOnlineShareStatus()} + ${renderOnlineError()}
${renderBoard(state.setupBoard, { kind: "setup", title: translate("game.yourFleet") })} @@ -1934,7 +2187,8 @@ function renderOnlineRoom(snapshot) { ${renderOnlineStatus(snapshot)} - ${state.online.error ? `

${translate("online.error", { message: state.online.error })}

` : ""} + ${renderOnlineShareStatus()} + ${renderOnlineError()}
${snapshot ? renderOnlineSnapshot(snapshot) : renderBoard(state.setupBoard, { kind: "setup", title: translate("game.yourFleet") })} @@ -2286,7 +2540,11 @@ function renderResultModal({ winnerId, playerId = winnerId, log, newGameAction, ${ state.resultCopyStatus ? `

${translate( - state.resultCopyStatus === "copied" ? "result.copySuccess" : "share.failed", + state.resultCopyStatus === "copied" + ? "result.copySuccess" + : state.resultCopyStatus === "invite-copied" + ? "online.inviteCopied" + : state.resultCopyStatus === "link-copied" ? "share.linkCopied" : "share.failed", )}

` : "" } @@ -2910,7 +3168,12 @@ root.addEventListener("click", async (event) => { if (action === "share-telegram") await shareRoom(); if (action === "battle-tab") selectBattleTab(button.dataset.tab); if (action === "auth-telegram-oidc") await startTelegramOidc(); - if (action === "auth-telegram-retry") await loadTelegramAuthCapability(); + if (action === "auth-telegram-retry") { + await (isTelegramMiniApp ? authenticateTelegramMiniApp() : loadTelegramAuthCapability()); + } + if (action === "auth-miniapp-open" || action === "auth-miniapp-reopen") { + await openTelegramMainMiniApp(); + } if (action === "auth-logout") await logoutAuth(); if (action === "refresh-profile") await refreshProfile(); if (action === "refresh-leaderboard") await refreshLeaderboard(); @@ -2982,8 +3245,9 @@ function showOnline() { state.setupHover = null; state.setupError = ""; state.online.roomCodeInput = ""; - state.online.error = ""; + clearOnlineError(); state.online.status = ""; + state.online.shareStatus = ""; state.battleTab = "target"; state.tacticalAdvisorOpen = true; state.resultModalDismissed = null; @@ -3008,6 +3272,14 @@ function startTraining(scenarioId = state.training.scenarioId) { } async function handlePlatformBack() { + if (state.leaveBattleDialog) { + cancelLeaveBattle(); + return true; + } + if (isResultModalVisible()) { + closeResultModal(); + return true; + } if (state.settingsOpen) { state.settingsOpen = false; render(); @@ -3023,7 +3295,16 @@ async function handlePlatformBack() { render(); return true; } - if (["archive", "replay"].includes(state.screen)) { + if (isTacticalAdvisorVisible()) { + state.tacticalAdvisorOpen = false; + render(); + return true; + } + if (state.screen === "replay") { + await backToReplayArchive(); + return true; + } + if (state.screen === "archive") { await goToMenu(); return true; } @@ -3033,6 +3314,21 @@ async function handlePlatformBack() { return false; } +function isResultModalVisible() { + const resultKey = currentResultKey(); + return Boolean(resultKey && state.resultModalDismissed !== resultKey); +} + +function isTacticalAdvisorVisible() { + return Boolean( + state.tacticalAdvisorOpen + && ( + state.screen === "playing" + || (state.screen === "online" && state.online.snapshot) + ) + ); +} + async function requestLeaveBattle(transition = null) { if (state.leaveBattleDialog) { if (transition) return false; @@ -3060,6 +3356,70 @@ function hasUnfinishedBattle() { return false; } +function syncClosingConfirmation(forceApply = false) { + if (!isTelegramMiniApp || typeof platform.setClosingConfirmation !== "function") return; + closingConfirmationDesired = hasUnfinishedBattle(); + if ( + closingConfirmationOperation + || (!forceApply && closingConfirmationApplied === closingConfirmationDesired) + ) return; + + const attempted = closingConfirmationDesired; + let providerOperation; + try { + providerOperation = platform.setClosingConfirmation(attempted); + } catch (error) { + reportRuntimeError(error); + return; + } + + closingConfirmationOperation = Promise.resolve(providerOperation).then( + () => { + closingConfirmationApplied = attempted; + closingConfirmationOperation = null; + if (closingConfirmationDesired !== closingConfirmationApplied) { + syncClosingConfirmation(); + } + }, + (error) => { + closingConfirmationOperation = null; + reportRuntimeError(error); + if (closingConfirmationDesired !== attempted) { + syncClosingConfirmation(true); + } + }, + ); +} + +function syncBackButtonVisibility() { + if (!isTelegramMiniApp || typeof platform.setBackButtonVisible !== "function") return; + const enabled = Boolean( + state.screen !== "menu" + || state.leaveBattleDialog + || isResultModalVisible() + || state.settingsOpen + || state.profileOpen + || state.leaderboardOpen + || isTacticalAdvisorVisible() + ); + if (backButtonVisibility === enabled) return; + + backButtonVisibility = enabled; + const generation = ++backButtonVisibilityGeneration; + let operation; + try { + operation = platform.setBackButtonVisible(enabled); + } catch (error) { + if (generation === backButtonVisibilityGeneration) backButtonVisibility = null; + reportRuntimeError(error); + return; + } + void Promise.resolve(operation).catch((error) => { + if (generation === backButtonVisibilityGeneration) backButtonVisibility = null; + reportRuntimeError(error); + }); +} + function cancelLeaveBattle() { const returnFocus = leaveDialogReturnFocus; pendingLeaveTransition = null; @@ -3098,16 +3458,27 @@ async function handlePlatformDeepLink(rawUrl) { } return redeemTelegramTicket(route.ticket); } + return navigateToInternalRoute(route); +} + +async function navigateToInternalRoute( + route, + { joinRoom = false, replayHistoryMode = "push" } = {}, +) { return requestLeaveBattle(async () => { try { if (route.type === "room") { - return await appNavigation.run(() => { + return await appNavigation.run(async () => { showOnline(); state.online.roomCodeInput = route.roomCode; render(); + if (joinRoom) await onlineJoin(); }); } - return await openArchivedReplay(route.replayId, { source: "direct" }); + return await openArchivedReplay(route.replayId, { + source: "direct", + historyMode: replayHistoryMode, + }); } catch { return false; } @@ -3130,8 +3501,9 @@ function applyMenuState({ updateHistory }) { state.mode = null; state.game = null; state.training.session = null; - state.online.error = ""; + clearOnlineError(); state.online.status = ""; + state.online.shareStatus = ""; state.resultModalDismissed = null; state.resultCopyStatus = ""; resetResultReplayPlayback(); @@ -3178,7 +3550,7 @@ async function openReplayArchive({ historyMode = "push" } = {}) { }); } -async function openArchivedReplay(id, { source = "direct" } = {}) { +async function openArchivedReplay(id, { source = "direct", historyMode = "push" } = {}) { const replayId = replayIdFromSearch(`?replay=${encodeURIComponent(id || "")}`); if (!replayId) { return false; @@ -3199,7 +3571,9 @@ async function openArchivedReplay(id, { source = "direct" } = {}) { state.replayArchive.openedFromArchive = source === "archive"; state.archive.requestId += 1; state.archive.loading = false; - updateReplayHistory(replayId, "push", "replay", { replaySource: source }); + if (historyMode !== "none") { + updateReplayHistory(replayId, historyMode, "replay", { replaySource: source }); + } await loadArchivedReplay(replayId); }); } @@ -3268,6 +3642,20 @@ function selectArchivedReplayTab(tab) { async function copyArchivedReplayLink() { const replayId = state.replayArchive.requestedId; + if (isTelegramMiniApp) { + let url = ""; + try { + url = telegramReplayUrl(state.auth.telegramBotUsername, replayId); + } catch { + // Invalid launch configuration falls through to the existing error status. + } + const outcome = url + ? await shareWithTelegramFallback(translate("replayArchive.title"), url) + : { shared: false, copied: false }; + state.replayArchive.copyStatus = outcome.shared ? "" : outcome.copied ? "copied" : "error"; + render(); + return; + } const url = replayUrlForId(canonicalReplayBaseUrl, replayId); if (!url) { return; @@ -3353,6 +3741,7 @@ function closeLeaderboardPopover() { } async function toggleTheme() { + hasExplicitThemePreference = true; state.theme = state.theme === "dark" ? "light" : "dark"; render(); await preferenceCoordinator.write("theme", state.theme); @@ -3770,15 +4159,15 @@ function runAgentTurns(game) { async function onlineCreate() { if (!requireOnline((message) => { - state.online.error = message; + setOnlineError(message); })) return; if (!isOnlineAuthReady()) { - state.online.error = translate("online.authRequired"); + setOnlineError(translate("online.authRequired")); render(); return; } if (!hasFullFleet(state.setupBoard)) { - state.online.error = translate("setup.needFleet"); + setOnlineError(translate("setup.needFleet")); render(); return; } @@ -3802,15 +4191,15 @@ async function onlineCreate() { async function onlineJoin() { if (!requireOnline((message) => { - state.online.error = message; + setOnlineError(message); })) return; if (!isOnlineAuthReady()) { - state.online.error = translate("online.authRequired"); + setOnlineError(translate("online.authRequired")); render(); return; } if (!hasFullFleet(state.setupBoard)) { - state.online.error = translate("setup.needFleet"); + setOnlineError(translate("setup.needFleet")); render(); return; } @@ -3835,7 +4224,7 @@ async function onlineJoin() { state.setupSelectedShipId = firstUnplacedShipId(board); render(); }, - onError: handleOnlineConnectionError, + onError: handleOnlineJoinError, }); } @@ -3843,21 +4232,32 @@ function prepareOnlineConnection() { state.online.session = null; state.online.snapshot = null; state.online.status = ""; - state.online.error = ""; + state.online.shareStatus = ""; + clearOnlineError(); render(); } -function handleOnlineConnectionError(error) { +function handleOnlineConnectionError(error, errorKey = "") { state.online.session = null; state.online.snapshot = null; state.online.status = ""; - state.online.error = error.message; + state.online.shareStatus = ""; + setOnlineError(error, errorKey); render(); } +function handleOnlineJoinError(error) { + const message = String(error?.message ?? ""); + if (isTelegramMiniApp && telegramUnavailableRoomErrorPattern.test(message.trim())) { + handleOnlineConnectionError(error, "online.roomUnavailable"); + return; + } + handleOnlineConnectionError(error); +} + async function onlineRematch() { if (!requireOnline((message) => { - state.online.error = message; + setOnlineError(message); })) return; await withOnlineError(async () => { const snapshot = state.online.snapshot; @@ -3878,7 +4278,7 @@ async function onlineRematch() { function handleOnlineShot(coordinate) { if (!requireOnline((message) => { - state.online.error = message; + setOnlineError(message); })) return; playSound("shot"); withOnlineError(async () => { @@ -3933,8 +4333,8 @@ async function shareBattleSummary() { } const report = buildBattleReport(context.log, context.winnerId, context.playerId); const summaryText = buildBattleSummaryText(report, context); - const shared = await shareWithTelegramFallback(summaryText, canonicalReplayBaseUrl); - state.resultCopyStatus = shared ? "" : "share-failed"; + const outcome = await shareWithTelegramFallback(summaryText, canonicalReplayBaseUrl); + state.resultCopyStatus = outcome.shared ? "" : outcome.copied ? "link-copied" : "share-failed"; render(); } @@ -3945,9 +4345,28 @@ async function shareRoom() { } const showingResult = Boolean(currentBattleResultContext()); const text = translate("online.shareText", { code: roomCode }); - const shared = await shareWithTelegramFallback(text, canonicalReplayBaseUrl); - state.online.error = shared ? "" : translate("share.failed"); - if (showingResult) state.resultCopyStatus = shared ? "" : "share-failed"; + let url = canonicalReplayBaseUrl; + if (isTelegramMiniApp) { + try { + url = telegramRoomInviteUrl(state.auth.telegramBotUsername, roomCode); + } catch { + url = ""; + } + } + state.online.shareStatus = ""; + if (showingResult) state.resultCopyStatus = ""; + render(); + const outcome = url + ? await shareWithTelegramFallback(text, url) + : { shared: false, copied: false }; + state.online.shareStatus = outcome.shared + ? "" + : outcome.copied ? "invite-copied" : "share-failed"; + if (showingResult) { + state.resultCopyStatus = outcome.shared + ? "" + : outcome.copied ? "invite-copied" : "share-failed"; + } render(); } @@ -3958,14 +4377,16 @@ async function shareWithTelegramFallback(text, url) { text: text, url: url, }); - if (result.shared) return true; + if (result.shared) return { shared: true, copied: false }; + if (result.copied) return { shared: false, copied: true }; + if (isTelegramMiniApp) return { shared: false, copied: false }; const telegramUrl = new URL("https://t.me/share/url"); telegramUrl.searchParams.set("url", url); telegramUrl.searchParams.set("text", text); await platform.openExternalUrl(telegramUrl.toString()); - return true; + return { shared: true, copied: false }; } catch { - return false; + return { shared: false, copied: false }; } } @@ -3986,7 +4407,7 @@ function remoteHandlers() { render(); }, onError(error) { - state.online.error = error.message; + setOnlineError(error); render(); }, onMessage(message) { @@ -3999,7 +4420,7 @@ function remoteHandlers() { } } if (message.type === "error") { - state.online.error = message.message; + setOnlineError(message.message); } render(); }, @@ -4007,7 +4428,7 @@ function remoteHandlers() { } function mountTelegramLoginWidget() { - if (platform.isNative() || state.auth.method !== "legacy") { + if (isTelegramMiniApp || platform.isNative() || state.auth.method !== "legacy") { return; } const slot = document.querySelector("#telegram-login-slot"); @@ -4951,10 +5372,10 @@ function syncMenuMusic() { async function withOnlineError(action) { try { - state.online.error = ""; + clearOnlineError(); await action(); } catch (error) { - state.online.error = error.message; + setOnlineError(error); render(); } } @@ -4968,7 +5389,26 @@ function resetOnlineConnectionState() { state.online.session = null; state.online.snapshot = null; state.online.status = ""; + state.online.shareStatus = ""; + clearOnlineError(); +} + +function clearOnlineError() { state.online.error = ""; + state.online.errorKey = ""; +} + +function setOnlineError(error, errorKey = "") { + state.online.error = typeof error === "string" ? error : String(error?.message ?? ""); + state.online.errorKey = errorKey; +} + +function renderOnlineError() { + const message = state.online.errorKey + ? translate(state.online.errorKey) + : state.online.error; + if (!message) return ""; + return ``; } function currentPreset() { @@ -5140,7 +5580,9 @@ function shipSprite(cell, kind, board, coordinate) { const orientation = shipOrientation(ship); const state = shipState(ship); const direction = orientation === "horizontal" ? "h" : "v"; - const path = `./assets/images/ships/ship-${ship.length}-${direction}-${state}.png`; + const path = assetUrl( + `./assets/images/ships/ship-${ship.length}-${direction}-${state}.png`, + ); return ``; } @@ -5177,18 +5619,18 @@ function markerSprite(cell, kind) { } const path = cell.markerType === "mine" - ? "./assets/images/special/mine.png" - : "./assets/images/special/minesweeper-2-h-normal.png"; + ? assetUrl("./assets/images/special/mine.png") + : assetUrl("./assets/images/special/minesweeper-2-h-normal.png"); return ``; } function shotSprite(cell, kind, board, coordinate) { const paths = { - miss: "./assets/images/markers/miss-blue-dot.png", - hit: "./assets/images/effects/hit-explosion-smoke.png", - sunk: "./assets/images/effects/sunk-destruction-smoke.png", - mine: "./assets/images/special/mine-triggered.png", - sweeper: "./assets/images/special/mine-disabled.png", + miss: assetUrl("./assets/images/markers/miss-blue-dot.png"), + hit: assetUrl("./assets/images/effects/hit-explosion-smoke.png"), + sunk: assetUrl("./assets/images/effects/sunk-destruction-smoke.png"), + mine: assetUrl("./assets/images/special/mine-triggered.png"), + sweeper: assetUrl("./assets/images/special/mine-disabled.png"), }; const path = paths[cell.shot]; if (!path) { @@ -5259,6 +5701,7 @@ function onlineStatusText(status) { connecting: "online.connecting", connected: "online.connected", copied: "online.copied", + "invite-copied": "online.inviteCopied", disconnected: "online.disconnected", }; return translate(keys[status] ?? "online.waiting"); @@ -5300,6 +5743,13 @@ function renderOnlineStatus(snapshot) { return lines.map((line) => `

${line}

`).join(""); } +function renderOnlineShareStatus() { + if (!state.online.shareStatus) return ""; + const failed = state.online.shareStatus === "share-failed"; + const message = translate(failed ? "share.failed" : "online.inviteCopied"); + return `

${message}

`; +} + function currentResultKey() { if (state.screen === "playing" && state.game?.phase === "finished") { return localResultKey(state.game); diff --git a/src/asset-url.js b/src/asset-url.js new file mode 100644 index 0000000..f1d7b17 --- /dev/null +++ b/src/asset-url.js @@ -0,0 +1,3 @@ +export function assetUrl(source) { + return new URL(source, import.meta.url).href; +} diff --git a/src/audio.js b/src/audio.js index 3b40bd1..90ba7d6 100644 --- a/src/audio.js +++ b/src/audio.js @@ -154,7 +154,7 @@ export function createAudioController() { return false; } - const element = new Audio(resolveAudioUrl(source)); + const element = new Audio(source); element.loop = true; element.preload = "auto"; element.volume = MUSIC_VOLUME; @@ -198,7 +198,3 @@ function chooseMenuTrack() { const index = Math.floor(Math.random() * menuMusicTracks.length); return menuMusicTracks[index]; } - -function resolveAudioUrl(source) { - return new URL(source, import.meta.url).href; -} diff --git a/src/core/audio.js b/src/core/audio.js index f17658f..d7a579a 100644 --- a/src/core/audio.js +++ b/src/core/audio.js @@ -1,6 +1,8 @@ +import { assetUrl } from "../asset-url.js"; + export const menuMusicTracks = [ - "./assets/audio/menu-loop.mp3", - "./assets/audio/menu-loop-v2.mp3", + assetUrl("./assets/audio/menu-loop.mp3"), + assetUrl("./assets/audio/menu-loop-v2.mp3"), ]; export const soundPresets = { diff --git a/src/i18n.js b/src/i18n.js index 40e4cc5..3af1565 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -34,6 +34,7 @@ const dictionaries = { "restore.unsupportedVersion": "This saved battle was created by a newer app version.", "restore.failed": "Could not restore the saved battle.", "share.failed": "Could not share.", + "share.linkCopied": "Link copied.", "audio.label": "Sound", "audio.on": "On", "audio.off": "Off", @@ -49,6 +50,11 @@ const dictionaries = { "auth.cancelled": "Telegram sign-in was cancelled. Try again when ready.", "auth.invalidTicket": "This sign-in link expired or is invalid. Please try again.", "auth.unavailable": "Telegram login is unavailable right now.", + "auth.miniAppOpenInTelegram": "Open Salvo in Telegram to sign in.", + "auth.miniAppOpenCommand": "Open in Telegram", + "auth.miniAppReopen": "This Telegram Mini App session expired. Reopen Salvo to sign in again.", + "auth.miniAppReopenCommand": "Reopen in Telegram", + "auth.miniAppAccountStatus": "Telegram Mini App account confirmed. Your existing profile and online progress are available.", "auth.retry": "Retry", "auth.valueNotice": "Save your profile and online progress. Local play remains available.", "auth.privacyNotice": "Read how account data is handled in the", @@ -387,6 +393,7 @@ const dictionaries = { "online.connecting": "Connecting", "online.connected": "Connected", "online.copied": "Room code copied", + "online.inviteCopied": "Room invite link copied.", "online.waiting": "Waiting for opponent", "online.setup": "Fleet sent. Waiting for opponent", "online.playing": "Online game in progress", @@ -397,6 +404,7 @@ const dictionaries = { "online.opponent": "Opponent: {player}", "online.ready": "Ready online", "online.fire": "Online shot", + "online.roomUnavailable": "This room is full, closed, or unavailable. Return to the online lobby and try another room.", "online.error": "Online error: {message}", "online.disconnected": "Disconnected", "board.row": "Row {row}", @@ -438,6 +446,7 @@ const dictionaries = { "restore.unsupportedVersion": "Этот сохранённый бой создан в более новой версии приложения.", "restore.failed": "Не удалось восстановить сохранённый бой.", "share.failed": "Не удалось поделиться.", + "share.linkCopied": "Ссылка скопирована.", "audio.label": "Звук", "audio.on": "Вкл", "audio.off": "Выкл", @@ -453,6 +462,11 @@ const dictionaries = { "auth.cancelled": "Вход через Telegram отменён. Повторите попытку, когда будете готовы.", "auth.invalidTicket": "Ссылка для входа недействительна или устарела. Попробуйте ещё раз.", "auth.unavailable": "Вход через Telegram сейчас недоступен.", + "auth.miniAppOpenInTelegram": "Откройте Залп в Telegram, чтобы войти.", + "auth.miniAppOpenCommand": "Открыть в Telegram", + "auth.miniAppReopen": "Сеанс Telegram Mini App истёк. Откройте Залп снова, чтобы войти.", + "auth.miniAppReopenCommand": "Открыть снова в Telegram", + "auth.miniAppAccountStatus": "Аккаунт Telegram Mini App подтверждён. Ваш существующий профиль и онлайн-прогресс доступны.", "auth.retry": "Повторить", "auth.valueNotice": "Сохраняйте профиль и прогресс онлайн. Локальная игра остаётся доступной.", "auth.privacyNotice": "О работе с данными аккаунта читайте в", @@ -791,6 +805,7 @@ const dictionaries = { "online.connecting": "Подключение", "online.connected": "Подключено", "online.copied": "Код комнаты скопирован", + "online.inviteCopied": "Ссылка-приглашение в комнату скопирована.", "online.waiting": "Ожидание соперника", "online.setup": "Флот отправлен. Ждем соперника", "online.playing": "Онлайн-игра идет", @@ -801,6 +816,7 @@ const dictionaries = { "online.opponent": "Соперник: {player}", "online.ready": "Готов онлайн", "online.fire": "Онлайн-выстрел", + "online.roomUnavailable": "Комната заполнена, закрыта или недоступна. Вернитесь в онлайн-лобби и выберите другую комнату.", "online.error": "Ошибка сети: {message}", "online.disconnected": "Отключено", "board.row": "Строка {row}", @@ -842,6 +858,7 @@ const dictionaries = { "restore.unsupportedVersion": "此保存的战斗来自更新版本的应用。", "restore.failed": "无法恢复保存的战斗。", "share.failed": "分享失败。", + "share.linkCopied": "链接已复制。", "audio.label": "声音", "audio.on": "开", "audio.off": "关", @@ -857,6 +874,11 @@ const dictionaries = { "auth.cancelled": "已取消 Telegram 登录,准备好后可重试。", "auth.invalidTicket": "此登录链接已过期或无效,请重试。", "auth.unavailable": "Telegram 登录暂时不可用。", + "auth.miniAppOpenInTelegram": "请在 Telegram 中打开 Salvo 以登录。", + "auth.miniAppOpenCommand": "在 Telegram 中打开", + "auth.miniAppReopen": "Telegram Mini App 会话已过期。请重新打开 Salvo 以登录。", + "auth.miniAppReopenCommand": "在 Telegram 中重新打开", + "auth.miniAppAccountStatus": "Telegram Mini App 账号已确认。您可以继续使用现有档案和在线进度。", "auth.retry": "重试", "auth.valueNotice": "保存个人档案和在线进度;本地游戏仍可使用。", "auth.privacyNotice": "账号数据处理方式请参阅", @@ -1195,6 +1217,7 @@ const dictionaries = { "online.connecting": "连接中", "online.connected": "已连接", "online.copied": "房间码已复制", + "online.inviteCopied": "房间邀请链接已复制。", "online.waiting": "等待对手", "online.setup": "舰队已发送,等待对手", "online.playing": "在线对局进行中", @@ -1205,6 +1228,7 @@ const dictionaries = { "online.opponent": "对手:{player}", "online.ready": "在线准备", "online.fire": "在线射击", + "online.roomUnavailable": "此房间已满、已关闭或不可用。请返回在线大厅并尝试其他房间。", "online.error": "网络错误:{message}", "online.disconnected": "已断开", "board.row": "行 {row}", diff --git a/src/index.html b/src/index.html index 08299ca..59deb40 100644 --- a/src/index.html +++ b/src/index.html @@ -1,5 +1,5 @@ - + @@ -14,6 +14,7 @@ window.SALVO_CONFIG = { workerUrl: "https://agents-salvo-room.if-ab6.workers.dev", telegramBotUsername: "agents_salvo_bot", + buildId: "dev", }; diff --git a/src/mobile-app-support.js b/src/mobile-app-support.js index aa6911f..85dfc36 100644 --- a/src/mobile-app-support.js +++ b/src/mobile-app-support.js @@ -54,6 +54,7 @@ export function startMobileAppServices({ hydratePreferences, hydrateSecureSession, refreshAuth, + processLaunch, refreshLeaderboard, onError, }) { @@ -74,12 +75,18 @@ export function startMobileAppServices({ : undefined ), ); + const launchReady = authReady.then(() => ( + typeof processLaunch === "function" + ? settleOperation(processLaunch, onError).then(() => undefined) + : undefined + )); const done = Promise.all([ runtimeReady, preferencesReady, secureSessionReady, leaderboardReady, authReady, + launchReady, ]).then(() => undefined); return { @@ -88,6 +95,7 @@ export function startMobileAppServices({ secureSessionReady, leaderboardReady, authReady, + launchReady, done, }; } diff --git a/src/mobile.js b/src/mobile.js index 12716a9..a87a45e 100644 --- a/src/mobile.js +++ b/src/mobile.js @@ -7,6 +7,9 @@ export function createMobileRuntime({ onNetwork, onDeepLink, onBack, + onSettings = () => {}, + onThemeChange = () => {}, + onViewportChange = () => {}, pauseAudio, resumeAudio, onRuntimeError, @@ -17,6 +20,7 @@ export function createMobileRuntime({ let lifecycleTail = Promise.resolve(); let networkEventVersion = 0; let networkTail = Promise.resolve(); + let platformReady = false; let started = false; let transitionTail = Promise.resolve(); @@ -107,11 +111,20 @@ export function createMobileRuntime({ const registerSubscriptions = async () => { const removers = []; + const registerOptional = async (name, listener) => { + const subscribe = platform[name]; + if (typeof subscribe !== "function") return; + const remove = await subscribe.call(platform, listener); + if (typeof remove === "function") removers.push(remove); + }; try { removers.push(await platform.onNetworkChange(handleNetworkChange)); removers.push(await platform.onDeepLink(onDeepLink)); removers.push(await platform.onBack(onBack)); removers.push(await platform.onLifecycleChange(handleLifecycle)); + await registerOptional("onSettings", onSettings); + await registerOptional("onThemeChange", onThemeChange); + await registerOptional("onViewportChange", onViewportChange); return removers; } catch (error) { const cleanup = await removeSubscriptions(removers); @@ -152,6 +165,10 @@ export function createMobileRuntime({ if (networkEventVersion === versionBeforeSample) { await queueNetworkDelivery(status, false); } + if (!platformReady && typeof platform.ready === "function") { + await platform.ready(); + platformReady = true; + } } catch (error) { const cleanup = await removeSubscriptions(removers); await Promise.all([networkTail, lifecycleTail]); diff --git a/src/platform/index.js b/src/platform/index.js index ea523a4..e55e3fc 100644 --- a/src/platform/index.js +++ b/src/platform/index.js @@ -1,9 +1,20 @@ import { Capacitor } from "@capacitor/core"; import { createNativePlatform } from "./native.js"; +import { createTelegramPlatform } from "./telegram.js"; import { createWebPlatform } from "./web.js"; -export function selectPlatform(isNative = Capacitor.isNativePlatform()) { - return isNative ? createNativePlatform() : createWebPlatform(); +export function selectPlatform( + isNative = Capacitor.isNativePlatform(), + { + runtime = globalThis.document?.documentElement?.dataset?.runtime, + telegramWebApp = globalThis.window?.Telegram?.WebApp, + } = {}, +) { + if (isNative) return createNativePlatform(); + if (runtime === "telegram") { + return createTelegramPlatform({ webApp: telegramWebApp }); + } + return createWebPlatform(); } export const platform = selectPlatform(); diff --git a/src/platform/native.js b/src/platform/native.js index e9c2652..ea51b22 100644 --- a/src/platform/native.js +++ b/src/platform/native.js @@ -93,6 +93,9 @@ export function createNativePlatform(plugins = defaultPlugins) { return { isNative: () => true, getPlatform: () => capacitor.getPlatform(), + isAvailable: () => true, + getLaunchData: () => "", + getStartParam: () => "", getNetworkStatus: () => network.getStatus(), onNetworkChange: (listener) => subscribe( network.addListener("networkStatusChange", listener), @@ -184,6 +187,13 @@ export function createNativePlatform(plugins = defaultPlugins) { "appStateChange", (event) => listener({ active: Boolean(event?.isActive) }), )), + onSettings: async () => () => {}, + ready: async () => {}, + setBackButtonVisible: async () => {}, + setClosingConfirmation: async () => {}, + getTheme: () => null, + onThemeChange: async () => () => {}, + onViewportChange: async () => () => {}, async hideSplash() { try { await splashScreen.hide(); diff --git a/src/platform/telegram.js b/src/platform/telegram.js new file mode 100644 index 0000000..0c1cbcd --- /dev/null +++ b/src/platform/telegram.js @@ -0,0 +1,502 @@ +const impactByEvent = { + placement: "light", + hit: "medium", + sunk: "heavy", +}; + +const notificationByEvent = { + invalid: "warning", + victory: "success", + defeat: "error", +}; + +const colorsByTheme = { + light: "#f4ecdc", + dark: "#07111f", +}; + +const insetSides = ["top", "right", "bottom", "left"]; +const buttonCleanupErrorMessage = "Telegram button cleanup failed"; +const buttonVisibilityErrorMessage = "Telegram button visibility update failed"; +const closingConfirmationErrorMessage = "Telegram closing confirmation update failed"; +const eventCleanupErrorMessage = "Telegram event cleanup failed"; +const settingsStorageErrorMessage = "Settings storage unavailable"; +const resolvedCleanup = Promise.resolve(); + +function noOp() {} + +function noOpCleanup() { + return resolvedCleanup; +} + +function readOr(getValue, fallback) { + try { + return getValue(); + } catch { + return fallback; + } +} + +function callOptional(target, name, ...args) { + try { + const method = target?.[name]; + if (typeof method !== "function") return false; + const result = method.apply(target, args); + if (result && typeof result.catch === "function") result.catch(noOp); + return true; + } catch { + return false; + } +} + +async function callOptionalAsync(target, name, ...args) { + try { + const method = target?.[name]; + if (typeof method !== "function") return false; + await method.apply(target, args); + return true; + } catch { + return false; + } +} + +function invokeListener(listener, value) { + try { + void Promise.resolve(listener(value)).catch(noOp); + } catch { + // Runtime callbacks must not escape into the Telegram provider. + } +} + +function numericValue(value) { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function readInsets(webApp, name) { + const source = readOr(() => webApp?.[name], null); + return Object.fromEntries(insetSides.map((side) => [ + side, + numericValue(readOr(() => source?.[side], 0)), + ])); +} + +function normalizeTelegramUrl(value) { + try { + const url = new URL(value); + if (url.protocol !== "https:") return null; + if (url.hostname === "telegram.me") url.hostname = "t.me"; + if (url.hostname !== "t.me") return null; + return url.toString(); + } catch { + return null; + } +} + +function createRetryableCleanup(entries, remove, errorMessage) { + let active = [...entries]; + let inFlight = null; + let completed = null; + + return () => { + if (completed) return completed; + if (inFlight) return inFlight; + + const attempt = (async () => { + const failed = []; + for (const entry of active) { + if (!(await remove(entry))) failed.push(entry); + } + active = failed; + if (active.length > 0) throw new Error(errorMessage); + })(); + let tracked; + tracked = attempt.finally(() => { + inFlight = null; + if (active.length === 0) completed = tracked; + }); + inFlight = tracked; + return tracked; + }; +} + +async function subscribeEvents(webApp, registrations) { + const active = []; + const remove = ([name, listener]) => ( + callOptionalAsync(webApp, "offEvent", name, listener) + ); + for (const [name, listener] of registrations) { + if (!(await callOptionalAsync(webApp, "onEvent", name, listener))) { + if (active.length === 0) return noOpCleanup; + const cleanup = createRetryableCleanup( + active, + remove, + eventCleanupErrorMessage, + ); + try { + await cleanup(); + } catch { + // Return the failed cleanup so the runtime can retry leaked listeners. + } + return cleanup; + } + active.push([name, listener]); + } + if (active.length === 0) return noOpCleanup; + + return createRetryableCleanup(active, remove, eventCleanupErrorMessage); +} + +function createButtonController(getButton, { visibilityControlled = false } = {}) { + let button = null; + let activeCount = 0; + let requestedVisible = !visibilityControlled; + let visibility = "hidden"; + let visibilityTail = Promise.resolve(); + + const resolveButton = () => { + if (button) return button; + button = readOr(getButton, null); + return button; + }; + + const reconcileVisibility = (errorMessage) => { + const transition = visibilityTail.then(async () => { + const desiredVisibility = activeCount > 0 && requestedVisible ? "visible" : "hidden"; + if (visibility === desiredVisibility) return; + + const method = desiredVisibility === "visible" ? "show" : "hide"; + if (!(await callOptionalAsync(button, method))) { + visibility = "unknown"; + throw new Error(errorMessage); + } + visibility = desiredVisibility; + }); + visibilityTail = transition.catch(noOp); + return transition; + }; + + const subscribe = async (listener) => { + const providerButton = resolveButton(); + const callback = () => invokeListener(listener); + if (!(await callOptionalAsync(providerButton, "onClick", callback))) { + return noOpCleanup; + } + + activeCount += 1; + try { + await reconcileVisibility(buttonVisibilityErrorMessage); + } catch { + // Keep the listener registered; a later visibility request retries the provider. + } + let listenerRegistered = true; + let inFlight = null; + let completed = null; + + return () => { + if (completed) return completed; + if (inFlight) return inFlight; + + let succeeded = false; + const attempt = (async () => { + if (listenerRegistered) { + if (!(await callOptionalAsync(providerButton, "offClick", callback))) { + throw new Error(buttonCleanupErrorMessage); + } + listenerRegistered = false; + activeCount -= 1; + } + await reconcileVisibility(buttonCleanupErrorMessage); + succeeded = true; + })(); + let tracked; + tracked = attempt.finally(() => { + inFlight = null; + if (succeeded) completed = tracked; + }); + inFlight = tracked; + return tracked; + }; + }; + + const setVisible = (enabled) => { + requestedVisible = Boolean(enabled); + return reconcileVisibility(buttonVisibilityErrorMessage); + }; + + const reconcile = () => reconcileVisibility(buttonVisibilityErrorMessage); + + return { reconcile, setVisible, subscribe }; +} + +export function createTelegramPlatform({ + webApp, + window: host = globalThis.window, + navigator: nav = globalThis.navigator, + storage, +} = {}) { + let sessionToken = ""; + const settingsStorage = storage === undefined + ? readOr(() => globalThis.localStorage, null) + : storage; + + const launchData = () => { + const value = readOr(() => webApp?.initData, ""); + return typeof value === "string" ? value : ""; + }; + + const getTheme = () => { + const value = readOr(() => webApp?.colorScheme, null); + return value === "light" || value === "dark" ? value : null; + }; + + const supportsVersion8 = () => readOr( + () => webApp?.isVersionAtLeast?.("8.0") === true, + false, + ); + + const getStyle = () => readOr( + () => host?.document?.documentElement?.style, + null, + ); + + const setCssPixelValue = (name, value) => { + callOptional(getStyle(), "setProperty", name, `${numericValue(value)}px`); + }; + + const viewportSnapshot = (safeAreaSupported, isStateStable) => ({ + height: numericValue(readOr(() => webApp?.viewportHeight, 0)), + stableHeight: numericValue(readOr(() => webApp?.viewportStableHeight, 0)), + isExpanded: readOr(() => webApp?.isExpanded === true, false), + isStateStable, + safeAreaInset: safeAreaSupported ? readInsets(webApp, "safeAreaInset") : null, + contentSafeAreaInset: safeAreaSupported + ? readInsets(webApp, "contentSafeAreaInset") + : null, + }); + + const updateViewportCss = (safeAreaSupported) => { + setCssPixelValue( + "--tg-viewport-height", + readOr(() => webApp?.viewportHeight, 0), + ); + setCssPixelValue( + "--tg-viewport-stable-height", + readOr(() => webApp?.viewportStableHeight, 0), + ); + if (!safeAreaSupported) return; + + for (const [property, value] of [ + ["safe-area-inset", readInsets(webApp, "safeAreaInset")], + ["content-safe-area-inset", readInsets(webApp, "contentSafeAreaInset")], + ]) { + for (const side of insetSides) { + setCssPixelValue(`--tg-${property}-${side}`, value[side]); + } + } + }; + + const networkStatus = () => { + const connected = readOr(() => nav?.onLine, true) !== false; + return { + connected, + connectionType: connected ? "unknown" : "none", + }; + }; + + const useSettingsStorage = async (operation) => { + try { + return await operation(settingsStorage); + } catch { + throw new Error(settingsStorageErrorMessage); + } + }; + + const backButtonController = createButtonController( + () => webApp?.BackButton, + { visibilityControlled: true }, + ); + const settingsButtonController = createButtonController( + () => webApp?.SettingsButton, + ); + + return { + isNative: () => false, + getPlatform: () => "telegram", + isAvailable: () => launchData().length > 0, + getLaunchData: launchData, + getStartParam() { + const value = readOr(() => webApp?.initDataUnsafe?.start_param, ""); + return typeof value === "string" ? value : ""; + }, + getNetworkStatus: async () => networkStatus(), + async onNetworkChange(listener) { + const online = () => invokeListener(listener, { + connected: true, + connectionType: "unknown", + }); + const offline = () => invokeListener(listener, { + connected: false, + connectionType: "none", + }); + const onlineRegistered = callOptional(host, "addEventListener", "online", online); + const offlineRegistered = callOptional(host, "addEventListener", "offline", offline); + let removed = false; + + return () => { + if (removed) return; + removed = true; + if (onlineRegistered) { + callOptional(host, "removeEventListener", "online", online); + } + if (offlineRegistered) { + callOptional(host, "removeEventListener", "offline", offline); + } + }; + }, + async share(payload) { + if (typeof payload?.url !== "string" || payload.url.length === 0) { + return { shared: false, copied: false }; + } + + const shareUrl = new URL("https://t.me/share/url"); + shareUrl.searchParams.set("url", payload.url); + if (typeof payload.text === "string" && payload.text.length > 0) { + shareUrl.searchParams.set("text", payload.text); + } + if (await callOptionalAsync(webApp, "openTelegramLink", shareUrl.toString())) { + return { shared: true, copied: false }; + } + + const clipboard = readOr(() => nav?.clipboard, null); + if (await callOptionalAsync(clipboard, "writeText", payload.url)) { + return { shared: false, copied: true }; + } + return { shared: false, copied: false }; + }, + async haptic(event) { + const feedback = readOr(() => webApp?.HapticFeedback, null); + const impact = impactByEvent[event]; + if (impact) { + await callOptionalAsync(feedback, "impactOccurred", impact); + return; + } + + const notification = notificationByEvent[event]; + if (notification) { + await callOptionalAsync(feedback, "notificationOccurred", notification); + } + }, + async openExternalUrl(url) { + const telegramUrl = normalizeTelegramUrl(url); + const method = telegramUrl ? "openTelegramLink" : "openLink"; + if (await callOptionalAsync(webApp, method, telegramUrl ?? url)) return; + await callOptionalAsync(host, "open", url, "_blank", "noopener,noreferrer"); + }, + closeExternalUrl: async () => {}, + onDeepLink: async () => noOpCleanup, + async onBack(listener) { + return backButtonController.subscribe(listener); + }, + async onLifecycleChange(listener) { + if (!supportsVersion8()) return noOpCleanup; + return subscribeEvents(webApp, [ + ["activated", () => invokeListener(listener, { active: true })], + ["deactivated", () => invokeListener(listener, { active: false })], + ]); + }, + async onSettings(listener) { + return settingsButtonController.subscribe(listener); + }, + async ready() { + await callOptionalAsync(webApp, "ready"); + await callOptionalAsync(webApp, "expand"); + const color = colorsByTheme[getTheme()] ?? colorsByTheme.light; + await callOptionalAsync(webApp, "setHeaderColor", color); + await callOptionalAsync(webApp, "setBackgroundColor", color); + const safeAreaSupported = supportsVersion8(); + updateViewportCss(safeAreaSupported); + if (safeAreaSupported) await callOptionalAsync(webApp, "requestFullscreen"); + try { + await settingsButtonController.reconcile(); + } catch { + // SettingsButton is optional and must not block Mini App startup. + } + }, + async setBackButtonVisible(enabled) { + await backButtonController.setVisible(enabled); + }, + async setClosingConfirmation(enabled) { + const updated = await callOptionalAsync( + webApp, + enabled ? "enableClosingConfirmation" : "disableClosingConfirmation", + ); + if (!updated) { + throw new Error(closingConfirmationErrorMessage); + } + }, + getTheme, + async onThemeChange(listener) { + return subscribeEvents(webApp, [[ + "themeChanged", + () => invokeListener(listener, getTheme()), + ]]); + }, + async onViewportChange(listener) { + const safeAreaSupported = supportsVersion8(); + let isStateStable = false; + const notify = (event) => { + const eventState = readOr(() => event?.isStateStable, undefined); + if (typeof eventState === "boolean") isStateStable = eventState; + updateViewportCss(safeAreaSupported); + invokeListener(listener, viewportSnapshot(safeAreaSupported, isStateStable)); + }; + updateViewportCss(safeAreaSupported); + const registrations = [["viewportChanged", notify]]; + if (safeAreaSupported) { + registrations.push( + ["safeAreaChanged", notify], + ["contentSafeAreaChanged", notify], + ); + } + return subscribeEvents(webApp, registrations); + }, + hideSplash: async () => {}, + configureSystemBars: async () => {}, + settings: { + async get(key) { + return useSettingsStorage(async (provider) => { + if (typeof provider?.getItem !== "function") { + throw new Error(settingsStorageErrorMessage); + } + const value = await provider.getItem(`salvo.${key}`); + return typeof value === "string" ? value : null; + }); + }, + async set(key, value) { + return useSettingsStorage(async (provider) => { + const storageKey = `salvo.${key}`; + if (value === null) { + if (typeof provider?.removeItem !== "function") { + throw new Error(settingsStorageErrorMessage); + } + await provider.removeItem(storageKey); + return; + } + if (typeof provider?.setItem !== "function") { + throw new Error(settingsStorageErrorMessage); + } + await provider.setItem(storageKey, String(value)); + }); + }, + }, + secureSession: { + get: async () => sessionToken, + async set(token) { + sessionToken = typeof token === "string" ? token : String(token ?? ""); + }, + async clear() { + sessionToken = ""; + }, + }, + }; +} diff --git a/src/platform/web.js b/src/platform/web.js index 1af9b4c..96eef96 100644 --- a/src/platform/web.js +++ b/src/platform/web.js @@ -6,6 +6,9 @@ export function createWebPlatform({ return { isNative: () => false, getPlatform: () => "web", + isAvailable: () => true, + getLaunchData: () => "", + getStartParam: () => "", getNetworkStatus: async () => ({ connected: nav?.onLine !== false, connectionType: nav?.onLine === false ? "none" : "unknown", @@ -45,6 +48,13 @@ export function createWebPlatform({ onDeepLink: async () => () => {}, onBack: async () => () => {}, onLifecycleChange: async () => () => {}, + onSettings: async () => () => {}, + ready: async () => {}, + setBackButtonVisible: async () => {}, + setClosingConfirmation: async () => {}, + getTheme: () => null, + onThemeChange: async () => () => {}, + onViewportChange: async () => () => {}, hideSplash: async () => {}, configureSystemBars: async () => {}, settings: { diff --git a/src/privacy.html b/src/privacy.html index 5a8fb32..5c5d7a1 100644 --- a/src/privacy.html +++ b/src/privacy.html @@ -36,12 +36,13 @@

Оператор и контакты

Оператор персональных данных: Agent Axiom, издатель игры «Залп» с package ID io.github.agentaxiom.salvo. По вопросам данных и удаления аккаунта используйте GitHub Issues.

Какие данные используются

Для входа через Telegram игра получает Telegram ID, отображаемое имя, Telegram username и URL фотографии профиля (profile photo). Также сохраняются статистика матчей (match statistics), рейтинг, достижения и повторы онлайн-боёв (battle replays), включая ходы и результат.

+

При запуске Telegram Mini App игра отправляет подписанные данные запуска Telegram в Cloudflare Workers для проверки личности. Исходные данные запуска не сохраняются. Проверенная личность Telegram использует те же записи профиля, что и вход через сайт или установленное приложение.

Зачем и где

Данные нужны для авторизации, профиля, лидерборда, онлайн-комнат и личного архива боёв. Публичный leaderboard показывает отображаемое имя, рейтинг и агрегированную статистику матчей. Telegram username и profile photo не публикуются в лидерборде. Статический сайт обслуживает Cloudflare Pages, серверную логику выполняет Cloudflare Workers, а аккаунты, статистика и повторы хранятся в Cloudflare D1.

Сроки и безопасность

Серверная сессия действует до 30 days (30 дней). В базе хранится только хеш session token; сам токен используется приложением для доступа к аккаунту. Данные профиля, матчей и повторов хранятся, пока вы не запросите удаление (until you request deletion).

Выбор игрока

-

Перед входом игра запрашивает явное согласие на обработку данных Telegram-аккаунта и игровой статистики. Против агента и на одном устройстве можно играть без входа (without signing in). В игре нет рекламы (no advertising) и нет аналитики поведения (no analytics). Для удаления аккаунта создайте обращение в GitHub Issues. Не указывайте в публичном обращении пароль, session token или другие секреты; сопровождающий проекта сообщит способ подтверждения аккаунта.

+

На сайте и в установленном приложении игра запрашивает явное согласие на обработку данных Telegram-аккаунта и игровой статистики перед входом через Telegram. В Telegram Mini App проверка личности начинается автоматически при открытии на основании подписанных данных запуска. На сайте и в установленном приложении против агента и на одном устройстве можно играть без входа (without signing in). В игре нет рекламы (no advertising) и нет аналитики поведения (no analytics). Для удаления аккаунта создайте обращение в GitHub Issues. Не указывайте в публичном обращении пароль, session token или другие секреты; сопровождающий проекта сообщит способ подтверждения аккаунта.

@@ -50,12 +51,13 @@

Data controller and contact

Data controller: Agent Axiom, publisher of Salvo under package ID io.github.agentaxiom.salvo. Use GitHub Issues for data questions and account deletion requests.

Data we process

Telegram sign-in provides your Telegram ID, display name, Telegram username, and profile photo URL. The game also stores match statistics, rating, achievements, and online battle replays, including moves and results.

+

When you open the Telegram Mini App, the game sends signed Telegram launch data to Cloudflare Workers for identity validation. Raw launch data is not persisted. The validated Telegram identity reuses the same profile records as sign-in on the website or installed app.

Purpose and processors

We use this data for authentication, player profiles, the leaderboard, online rooms, and your private battle archive. The public leaderboard shows the display name, rating, and aggregated match statistics. Telegram username and profile photo are not public in the leaderboard. Cloudflare Pages serves the site, Cloudflare Workers runs the server, and Cloudflare D1 stores account, match, and replay records.

Retention and security

A server session expires after 30 days. The database stores a hash of the session token, not the token itself. Profile, match, and replay records remain until you request deletion.

Your choices

-

The game requests explicit consent to process Telegram account data and gameplay statistics before signing in. You can play against the agent and on one device without signing in. Salvo includes no advertising and no analytics. To request account deletion, open a request at GitHub Issues. Do not include a password, session token, or other secret in a public issue; the maintainer will provide an account verification step.

+

On the website and installed app, the game requests explicit consent to process Telegram account data and gameplay statistics before Telegram sign-in. In the Telegram Mini App, identity validation starts automatically when you open it from signed launch data. You can play against the agent and on one device without signing in on the website and installed app. Salvo includes no advertising and no analytics. To request account deletion, open a request at GitHub Issues. Do not include a password, session token, or other secret in a public issue; the maintainer will provide an account verification step.

@@ -64,12 +66,13 @@

个人信息处理者与联系方式

个人信息处理者:Agent Axiom,package ID 为 io.github.agentaxiom.salvo 的《海战》发行方。数据问题和账号删除请求请通过 GitHub Issues 提交。

处理的数据

使用 Telegram 登录时,游戏会接收 Telegram ID、显示名称、Telegram username 和头像链接 (profile photo)。游戏还会保存对局统计 (match statistics)、评级、成就以及在线对局回放 (battle replays),包括行动和结果。

+

打开 Telegram Mini App 时,游戏会将已签名的 Telegram 启动数据发送到 Cloudflare Workers 进行身份验证。原始启动数据不会被持久保存。验证后的 Telegram 身份会复用通过网站或已安装应用登录时使用的同一份档案记录。

用途与处理服务

这些数据用于身份验证、玩家档案、排行榜、在线房间和个人对局档案。公开 leaderboard 显示显示名称、评级和汇总对局统计;Telegram username 和 profile photo 不会公开在排行榜中。Cloudflare Pages 提供静态网站,Cloudflare Workers 运行服务器逻辑,Cloudflare D1 保存账号、对局和回放记录。

保留与安全

服务器会话在 30 days(30 天)后到期。数据库仅保存 session token 的哈希值。档案、对局和回放数据会保留到您请求删除为止 (until you request deletion)。

您的选择

-

登录前,游戏会请求您明确同意处理 Telegram 账号数据和游戏统计信息。无需登录 (without signing in) 即可进行人机对战和同设备对战。游戏没有广告 (no advertising),也没有行为分析 (no analytics)。如需删除账号,请在 GitHub Issues 提交请求。请勿在公开问题中提供密码、session token 或其他密钥;维护者会提供账号验证步骤。

+

在网站和已安装的应用中,游戏会在通过 Telegram 登录前请求您明确同意处理 Telegram 账号数据和对局统计。在 Telegram Mini App 中,基于已签名启动数据的身份验证会在打开时自动开始。网站和已安装应用中,您无需登录即可与代理对战或在一台设备上游玩。Salvo 不含广告,也不进行行为分析。如需删除账号,请在 GitHub Issues 提交请求。请勿在公开 issue 中包含密码、session token 或其他秘密;维护者会提供账号验证步骤。

diff --git a/src/styles.css b/src/styles.css index bef07ad..4c0e614 100644 --- a/src/styles.css +++ b/src/styles.css @@ -41,6 +41,18 @@ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +html[data-runtime="telegram"] { + --salvo-safe-top: var(--tg-content-safe-area-inset-top, env(safe-area-inset-top, 0px)); + --salvo-safe-right: var(--tg-content-safe-area-inset-right, env(safe-area-inset-right, 0px)); + --salvo-safe-bottom: var(--tg-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); + --salvo-safe-left: var(--tg-content-safe-area-inset-left, env(safe-area-inset-left, 0px)); + --safe-top: var(--salvo-safe-top); + --safe-right: var(--salvo-safe-right); + --safe-bottom: var(--salvo-safe-bottom); + --safe-left: var(--salvo-safe-left); + min-height: var(--tg-viewport-stable-height, 100dvh); +} + :root[data-theme="dark"] { color-scheme: dark; --paper: #07111f; @@ -107,6 +119,11 @@ body { auto; } +html[data-runtime="telegram"] body { + min-height: var(--tg-viewport-stable-height, 100dvh); + overflow-x: clip; +} + :root[data-theme="dark"] body { background: linear-gradient(90deg, var(--notebook-grid) 1px, transparent 1px), @@ -234,6 +251,15 @@ input { padding: calc(22px + var(--safe-top)) var(--safe-right) calc(32px + var(--safe-bottom)) var(--safe-left); } +html[data-runtime="telegram"] .shell { + min-height: var(--tg-viewport-stable-height, 100dvh); + padding: + calc(22px + var(--salvo-safe-top)) + var(--salvo-safe-right) + calc(32px + var(--salvo-safe-bottom)) + var(--salvo-safe-left); +} + .offline-banner, .restore-banner { display: flex; @@ -2905,6 +2931,15 @@ input { backdrop-filter: blur(6px); } +html[data-runtime="telegram"] .modal-backdrop { + min-height: var(--tg-viewport-stable-height, 100dvh); + padding: + calc(18px + var(--salvo-safe-top)) + calc(18px + var(--salvo-safe-right)) + calc(18px + var(--salvo-safe-bottom)) + calc(18px + var(--salvo-safe-left)); +} + .result-modal { --result-modal-padding: 22px; display: grid; @@ -2922,6 +2957,15 @@ input { 0 0 34px var(--glow); } +html[data-runtime="telegram"] .result-modal { + max-height: calc( + var(--tg-viewport-stable-height, 100dvh) - + var(--salvo-safe-top) - + var(--salvo-safe-bottom) - + 36px + ); +} + .result-modal > span { color: var(--muted); font-size: 12px; @@ -4470,6 +4514,10 @@ input { padding-top: calc(12px + var(--safe-top)); } + html[data-runtime="telegram"] .shell { + padding-top: calc(12px + var(--salvo-safe-top)); + } + .icon-button { width: 44px; height: 44px; @@ -4784,6 +4832,16 @@ input { grid-template-columns: 1fr; } + html[data-runtime="telegram"] .replay-board-view { + overflow-x: clip; + scrollbar-gutter: auto; + } + + html[data-runtime="telegram"] .replay-board-view .board-panel { + width: 100%; + max-width: 100%; + } + .replay-board-view:not(.is-selected) { display: none; } diff --git a/src/telegram-auth-transport.js b/src/telegram-auth-transport.js new file mode 100644 index 0000000..f94bd8d --- /dev/null +++ b/src/telegram-auth-transport.js @@ -0,0 +1,267 @@ +const responseByteLimit = 16 * 1024; +const defaultTimeoutMs = 10_000; +const maximumTimeoutMs = 2_147_483_647; +const genericErrorMessage = "Telegram authentication unavailable"; + +export function createTelegramAuthTransport({ + workerUrl, + fetcher = globalThis.fetch, + timeoutMs = defaultTimeoutMs, +} = {}) { + const baseUrl = normalizeWorkerUrl(workerUrl); + const requestTimeoutMs = normalizeTimeout(timeoutMs); + if (typeof fetcher !== "function") { + throw new TypeError("A fetch function is required"); + } + + return async function request(path, init, validate, callerSignal) { + const requestAbort = createRequestAbort(callerSignal, requestTimeoutMs); + let responseStatus = 0; + try { + if (requestAbort.signal.aborted) throw new Error(); + const response = await waitForAbort( + fetcher(`${baseUrl}${path}`, { ...init, signal: requestAbort.signal }), + requestAbort.signal, + ); + responseStatus = httpStatus(response?.status); + return await parseBoundedResponse(response, validate, requestAbort.signal); + } catch (error) { + throw clientError(responseStatus || httpStatus(error?.status)); + } finally { + requestAbort.dispose(); + } + }; +} + +export function telegramJsonPost(body) { + return { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +export function validateTelegramPublicSession(value, validToken) { + if ( + !hasExactKeys(value, ["token", "user"]) + || typeof value.token !== "string" + || !validToken(value.token) + || !validPublicUser(value.user) + ) { + return null; + } + return { + token: value.token, + user: { + provider: value.user.provider, + id: value.user.id, + name: value.user.name, + username: value.user.username, + photoUrl: value.user.photoUrl, + }, + }; +} + +export function hasExactKeys(value, expectedKeys) { + return value !== null + && typeof value === "object" + && !Array.isArray(value) + && Object.keys(value).sort().join(",") === [...expectedKeys].sort().join(","); +} + +export function hasExplicitUrlPort(rawUrl) { + const authority = rawUrl.match(/^https:\/\/([^/?#]+)/i)?.[1] ?? ""; + const host = authority.split("@").at(-1); + return host.startsWith("[") ? /^\[[^\]]+\]:/.test(host) : host.includes(":"); +} + +export function hasUrlCredentials(rawUrl) { + const authority = rawUrl.match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i)?.[1] ?? ""; + return authority.includes("@"); +} + +function normalizeWorkerUrl(workerUrl) { + if (typeof workerUrl !== "string" || workerUrl.trim() !== workerUrl || workerUrl === "") { + throw new TypeError("Invalid Telegram auth worker URL"); + } + try { + const url = new URL(workerUrl); + if ( + url.protocol !== "https:" + || !url.hostname + || url.username + || url.password + || url.search + || url.hash + || workerUrl.includes("?") + || workerUrl.includes("#") + || hasUrlCredentials(workerUrl) + || url.port + || hasExplicitUrlPort(workerUrl) + ) { + throw new TypeError("Invalid Telegram auth worker URL"); + } + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString().replace(/\/$/, ""); + } catch (error) { + if (error instanceof TypeError && error.message === "Invalid Telegram auth worker URL") { + throw error; + } + throw new TypeError("Invalid Telegram auth worker URL"); + } +} + +function normalizeTimeout(timeoutMs) { + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > maximumTimeoutMs) { + throw new TypeError("Invalid Telegram auth timeout"); + } + return timeoutMs; +} + +async function parseBoundedResponse(response, validate, signal) { + const status = httpStatus(response?.status); + let readerOwnsBody = false; + try { + if (!response || response.ok !== true || typeof response.headers?.get !== "function") { + throw new Error(); + } + const contentType = response.headers.get("Content-Type") ?? ""; + if (!/^application\/json(?:\s*;|\s*$)/i.test(contentType)) throw new Error(); + + const declaredLength = Number(response.headers.get("Content-Length")); + if (Number.isFinite(declaredLength) && declaredLength > responseByteLimit) throw new Error(); + + if (!response.body || typeof response.body.getReader !== "function") throw new Error(); + readerOwnsBody = true; + const text = await readBoundedText(response.body, signal); + const value = validate(JSON.parse(text)); + if (value === null) throw new Error(); + return value; + } catch { + if (!readerOwnsBody) await cancelResponseBody(response, signal); + throw clientError(status); + } +} + +async function cancelResponseBody(response, signal) { + try { + const cancellation = response?.body?.cancel?.(); + if (cancellation) await waitForAbort(cancellation, signal); + } catch {} +} + +async function readBoundedText(body, signal) { + const reader = body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let byteCount = 0; + let text = ""; + let complete = false; + try { + while (true) { + if (signal.aborted) throw new Error(); + const { done, value } = await waitForAbort(reader.read(), signal); + if (done) { + complete = true; + break; + } + byteCount += value.byteLength; + if (byteCount > responseByteLimit) throw new Error(); + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); + } finally { + if (!complete) cancelReader(reader); + try { + reader.releaseLock(); + } catch {} + } +} + +function createRequestAbort(callerSignal, timeoutMs) { + if (callerSignal != null && !isAbortSignal(callerSignal)) { + throw new TypeError("Invalid caller abort signal"); + } + + const controller = new AbortController(); + const relayAbort = () => controller.abort(); + let listening = false; + if (callerSignal?.aborted) { + relayAbort(); + } else if (callerSignal) { + callerSignal.addEventListener("abort", relayAbort, { once: true }); + listening = true; + if (callerSignal.aborted) relayAbort(); + } + + const timer = controller.signal.aborted ? null : setTimeout(relayAbort, timeoutMs); + return { + signal: controller.signal, + dispose() { + if (timer !== null) clearTimeout(timer); + if (listening) callerSignal.removeEventListener("abort", relayAbort); + }, + }; +} + +function isAbortSignal(value) { + return value !== null + && typeof value === "object" + && typeof value.aborted === "boolean" + && typeof value.addEventListener === "function" + && typeof value.removeEventListener === "function"; +} + +function waitForAbort(value, signal) { + return new Promise((resolve, reject) => { + let listening = false; + let settled = false; + const finish = (settle, result) => { + if (settled) return; + settled = true; + if (listening) signal.removeEventListener("abort", onAbort); + settle(result); + }; + const onAbort = () => finish(reject, new Error()); + + Promise.resolve(value).then( + (result) => finish(resolve, result), + (error) => finish(reject, error), + ); + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + listening = true; + if (signal.aborted) onAbort(); + } + }); +} + +function cancelReader(reader) { + try { + const cancellation = reader.cancel(); + cancellation?.catch?.(() => {}); + } catch {} +} + +function validPublicUser(user) { + return hasExactKeys(user, ["provider", "id", "name", "username", "photoUrl"]) + && user.provider === "telegram" + && typeof user.id === "string" + && user.id.trim() !== "" + && user.id.length <= 128 + && typeof user.name === "string" + && user.name.length <= 256 + && typeof user.username === "string" + && user.username.length <= 128 + && typeof user.photoUrl === "string" + && user.photoUrl.length <= 2048; +} + +function httpStatus(value) { + return Number.isInteger(value) && value >= 100 && value <= 599 ? value : 0; +} + +function clientError(status) { + return Object.assign(new Error(genericErrorMessage), { status }); +} diff --git a/src/telegram-auth.js b/src/telegram-auth.js index 4f277ff..aa3c8c1 100644 --- a/src/telegram-auth.js +++ b/src/telegram-auth.js @@ -1,40 +1,18 @@ -const responseByteLimit = 16 * 1024; +import { + createTelegramAuthTransport, + hasExactKeys, + hasExplicitUrlPort, + hasUrlCredentials, + telegramJsonPost, + validateTelegramPublicSession, +} from "./telegram-auth-transport.js"; + const platforms = new Set(["web", "android", "ios"]); const ticketPattern = /^[A-Za-z0-9_-]{32,256}$/; const tokenPattern = /^[A-Za-z0-9_-]+$/; -const genericErrorMessage = "Telegram authentication unavailable"; -const defaultTimeoutMs = 10_000; -const maximumTimeoutMs = 2_147_483_647; - -export function createTelegramAuthClient({ - workerUrl, - fetcher = globalThis.fetch, - timeoutMs = defaultTimeoutMs, -} = {}) { - const baseUrl = normalizeWorkerUrl(workerUrl); - const requestTimeoutMs = normalizeTimeout(timeoutMs); - if (typeof fetcher !== "function") { - throw new TypeError("A fetch function is required"); - } - const request = async (path, init, validate, callerSignal) => { - const requestAbort = createRequestAbort(callerSignal, requestTimeoutMs); - let responseStatus = 0; - let response; - try { - if (requestAbort.signal.aborted) throw new Error(); - response = await waitForAbort( - fetcher(`${baseUrl}${path}`, { ...init, signal: requestAbort.signal }), - requestAbort.signal, - ); - responseStatus = httpStatus(response?.status); - return await parseBoundedResponse(response, validate, requestAbort.signal); - } catch (error) { - throw clientError(responseStatus || httpStatus(error?.status)); - } finally { - requestAbort.dispose(); - } - }; +export function createTelegramAuthClient(options) { + const request = createTelegramAuthTransport(options); return { capability({ signal } = {}) { @@ -44,181 +22,27 @@ export function createTelegramAuthClient({ if (!platforms.has(platform)) { return Promise.reject(new TypeError("Unsupported Telegram auth platform")); } - return request("/auth/telegram/mobile/start", jsonPost({ platform }), validateStart, signal); + return request( + "/auth/telegram/mobile/start", + telegramJsonPost({ platform }), + validateStart, + signal, + ); }, redeem(ticket, { signal } = {}) { if (typeof ticket !== "string" || !ticketPattern.test(ticket)) { return Promise.reject(new TypeError("Invalid Telegram auth ticket")); } - return request("/auth/telegram/mobile/redeem", jsonPost({ ticket }), validateRedeem, signal); - }, - }; -} - -function normalizeWorkerUrl(workerUrl) { - if (typeof workerUrl !== "string" || workerUrl.trim() !== workerUrl || workerUrl === "") { - throw new TypeError("Invalid Telegram auth worker URL"); - } - try { - const url = new URL(workerUrl); - if ( - url.protocol !== "https:" - || !url.hostname - || url.username - || url.password - || url.search - || url.hash - || workerUrl.includes("?") - || workerUrl.includes("#") - || hasCredentials(workerUrl) - || url.port - || hasExplicitPort(workerUrl) - ) { - throw new TypeError("Invalid Telegram auth worker URL"); - } - url.pathname = url.pathname.replace(/\/+$/, ""); - return url.toString().replace(/\/$/, ""); - } catch (error) { - if (error instanceof TypeError && error.message === "Invalid Telegram auth worker URL") { - throw error; - } - throw new TypeError("Invalid Telegram auth worker URL"); - } -} - -function normalizeTimeout(timeoutMs) { - if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > maximumTimeoutMs) { - throw new TypeError("Invalid Telegram auth timeout"); - } - return timeoutMs; -} - -function jsonPost(body) { - return { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }; -} - -async function parseBoundedResponse(response, validate, signal) { - const status = httpStatus(response?.status); - try { - if (!response || response.ok !== true || typeof response.headers?.get !== "function") { - throw new Error(); - } - const contentType = response.headers.get("Content-Type") ?? ""; - if (!/^application\/json(?:\s*;|\s*$)/i.test(contentType)) throw new Error(); - - const declaredLength = Number(response.headers.get("Content-Length")); - if (Number.isFinite(declaredLength) && declaredLength > responseByteLimit) throw new Error(); - - const text = await readBoundedText(response.body, signal); - const value = validate(JSON.parse(text)); - if (value === null) throw new Error(); - return value; - } catch { - throw clientError(status); - } -} - -async function readBoundedText(body, signal) { - if (!body || typeof body.getReader !== "function") throw new Error(); - const reader = body.getReader(); - const decoder = new TextDecoder("utf-8", { fatal: true }); - let byteCount = 0; - let text = ""; - let complete = false; - try { - while (true) { - if (signal.aborted) throw new Error(); - const { done, value } = await waitForAbort(reader.read(), signal); - if (done) { - complete = true; - break; - } - byteCount += value.byteLength; - if (byteCount > responseByteLimit) throw new Error(); - text += decoder.decode(value, { stream: true }); - } - return text + decoder.decode(); - } finally { - if (!complete) cancelReader(reader); - try { - reader.releaseLock(); - } catch {} - } -} - -function createRequestAbort(callerSignal, timeoutMs) { - if (callerSignal != null && !isAbortSignal(callerSignal)) { - throw new TypeError("Invalid caller abort signal"); - } - - const controller = new AbortController(); - const relayAbort = () => controller.abort(); - let listening = false; - if (callerSignal?.aborted) { - relayAbort(); - } else if (callerSignal) { - callerSignal.addEventListener("abort", relayAbort, { once: true }); - listening = true; - } - - const timer = controller.signal.aborted - ? null - : setTimeout(relayAbort, timeoutMs); - - return { - signal: controller.signal, - dispose() { - if (timer !== null) clearTimeout(timer); - if (listening) callerSignal.removeEventListener("abort", relayAbort); + return request( + "/auth/telegram/mobile/redeem", + telegramJsonPost({ ticket }), + validateRedeem, + signal, + ); }, }; } -function isAbortSignal(value) { - return value !== null - && typeof value === "object" - && typeof value.aborted === "boolean" - && typeof value.addEventListener === "function" - && typeof value.removeEventListener === "function"; -} - -function waitForAbort(value, signal) { - return new Promise((resolve, reject) => { - let listening = false; - let settled = false; - const finish = (settle, result) => { - if (settled) return; - settled = true; - if (listening) signal.removeEventListener("abort", onAbort); - settle(result); - }; - const onAbort = () => finish(reject, new Error()); - - Promise.resolve(value).then( - (result) => finish(resolve, result), - (error) => finish(reject, error), - ); - if (signal.aborted) { - onAbort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); - listening = true; - if (signal.aborted) onAbort(); - } - }); -} - -function cancelReader(reader) { - try { - const cancellation = reader.cancel(); - cancellation?.catch?.(() => {}); - } catch {} -} - function validateCapability(value) { if (!hasExactKeys(value, ["method"])) return null; return value.method === "legacy" || value.method === "oidc" @@ -240,8 +64,8 @@ function validateStart(value) { || url.password || url.hash || value.authorizationUrl.includes("#") - || hasCredentials(value.authorizationUrl) - || hasExplicitPort(value.authorizationUrl) + || hasUrlCredentials(value.authorizationUrl) + || hasExplicitUrlPort(value.authorizationUrl) ) { return null; } @@ -252,62 +76,5 @@ function validateStart(value) { } function validateRedeem(value) { - if ( - !hasExactKeys(value, ["token", "user"]) - || typeof value.token !== "string" - || !tokenPattern.test(value.token) - || !validPublicUser(value.user) - ) { - return null; - } - return { - token: value.token, - user: { - provider: value.user.provider, - id: value.user.id, - name: value.user.name, - username: value.user.username, - photoUrl: value.user.photoUrl, - }, - }; -} - -function validPublicUser(user) { - return hasExactKeys(user, ["provider", "id", "name", "username", "photoUrl"]) - && user.provider === "telegram" - && typeof user.id === "string" - && user.id.trim() !== "" - && user.id.length <= 128 - && typeof user.name === "string" - && user.name.length <= 256 - && typeof user.username === "string" - && user.username.length <= 128 - && typeof user.photoUrl === "string" - && user.photoUrl.length <= 2048; -} - -function hasExactKeys(value, expectedKeys) { - return value !== null - && typeof value === "object" - && !Array.isArray(value) - && Object.keys(value).sort().join(",") === [...expectedKeys].sort().join(","); -} - -function hasExplicitPort(rawUrl) { - const authority = rawUrl.match(/^https:\/\/([^/?#]+)/i)?.[1] ?? ""; - const host = authority.split("@").at(-1); - return host.startsWith("[") ? /^\[[^\]]+\]:/.test(host) : host.includes(":"); -} - -function hasCredentials(rawUrl) { - const authority = rawUrl.match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i)?.[1] ?? ""; - return authority.includes("@"); -} - -function httpStatus(value) { - return Number.isInteger(value) && value >= 100 && value <= 599 ? value : 0; -} - -function clientError(status) { - return Object.assign(new Error(genericErrorMessage), { status }); + return validateTelegramPublicSession(value, (token) => tokenPattern.test(token)); } diff --git a/src/telegram-launch.js b/src/telegram-launch.js new file mode 100644 index 0000000..f817a87 --- /dev/null +++ b/src/telegram-launch.js @@ -0,0 +1,59 @@ +const botUsernamePattern = /^[A-Za-z][A-Za-z0-9_]{4,31}$/; +const roomCodePattern = /^[A-Z0-9]{4,12}$/; +const replayIdPattern = /^[A-Za-z0-9-]{1,128}$/; +const roomStartPattern = /^room_([A-Z0-9]{4,12})$/; +const replayStartPattern = /^replay_([A-Za-z0-9-]{1,128})$/; + +export function parseTelegramStartParam(value) { + if (typeof value !== "string") return null; + + const roomMatch = roomStartPattern.exec(value); + if (roomMatch) { + return { type: "room", roomCode: roomMatch[1] }; + } + + const replayMatch = replayStartPattern.exec(value); + if (replayMatch) { + return { type: "replay", replayId: replayMatch[1] }; + } + return null; +} + +export function telegramRoomInviteUrl(botUsername, roomCode) { + requireBotUsername(botUsername); + if (typeof roomCode !== "string" || !roomCodePattern.test(roomCode)) { + throw new TypeError("Invalid Telegram room code"); + } + return telegramLaunchUrl(botUsername, ["room", roomCode].join("_")); +} + +export function telegramReplayUrl(botUsername, replayId) { + requireBotUsername(botUsername); + if (typeof replayId !== "string" || !replayIdPattern.test(replayId)) { + throw new TypeError("Invalid Telegram replay ID"); + } + return telegramLaunchUrl(botUsername, ["replay", replayId].join("_")); +} + +export function telegramMainMiniAppUrl(botUsername) { + requireBotUsername(botUsername); + const url = new URL("https://t.me/"); + url.pathname = botUsername; + url.search = "?startapp"; + return url.toString(); +} + +function requireBotUsername(botUsername) { + if (typeof botUsername !== "string" || !botUsernamePattern.test(botUsername)) { + throw new TypeError("Invalid Telegram bot username"); + } +} + +function telegramLaunchUrl(botUsername, startParam) { + const url = new URL("https://t.me/"); + url.pathname = botUsername; + const search = new URLSearchParams(); + search.set("startapp", startParam); + url.search = search; + return url.toString(); +} diff --git a/src/telegram-mini-app-auth.js b/src/telegram-mini-app-auth.js new file mode 100644 index 0000000..2f3c796 --- /dev/null +++ b/src/telegram-mini-app-auth.js @@ -0,0 +1,37 @@ +import { + createTelegramAuthTransport, + telegramJsonPost, + validateTelegramPublicSession, +} from "./telegram-auth-transport.js"; + +const maxInitDataBytes = 16 * 1024; +const tokenPattern = /^[A-Za-z0-9_-]{43}$/; +const textEncoder = new TextEncoder(); + +export function createTelegramMiniAppAuthClient(options) { + const request = createTelegramAuthTransport(options); + + return { + authenticate(initData, { signal } = {}) { + if (!validInitData(initData)) { + return Promise.reject(new TypeError("Invalid Telegram Mini App initData")); + } + return request( + "/auth/telegram/miniapp", + telegramJsonPost({ initData }), + validateSession, + signal, + ); + }, + }; +} + +function validInitData(initData) { + return typeof initData === "string" + && initData.length > 0 + && textEncoder.encode(initData).byteLength <= maxInitDataBytes; +} + +function validateSession(value) { + return validateTelegramPublicSession(value, (token) => tokenPattern.test(token)); +} diff --git a/src/telegram/index.html b/src/telegram/index.html new file mode 100644 index 0000000..f0ae32a --- /dev/null +++ b/src/telegram/index.html @@ -0,0 +1,26 @@ + + + + + + + + + Salvo + + + + + + +
+ + + + diff --git a/tests/app-behavior-harness.mjs b/tests/app-behavior-harness.mjs index 4e2fdc2..abc776d 100644 --- a/tests/app-behavior-harness.mjs +++ b/tests/app-behavior-harness.mjs @@ -26,6 +26,16 @@ const scenarios = { "auth-native-callback": runAuthNativeCallbackScenario, "auth-races": runAuthRacesScenario, "auth-bootstrap": runAuthBootstrapScenario, + "telegram-bootstrap": runTelegramBootstrapScenario, + "telegram-launch-routing": runTelegramLaunchRoutingScenario, + "telegram-launch-retry": runTelegramLaunchRetryScenario, + "telegram-launch-authority": runTelegramLaunchAuthorityScenario, + "telegram-launch-sharing": runTelegramLaunchSharingScenario, + "telegram-share-status-race": runTelegramShareStatusRaceScenario, + "telegram-auth-recovery": runTelegramAuthRecoveryScenario, + "telegram-runtime": runTelegramRuntimeScenario, + "haptic-runtime": runHapticRuntimeScenario, + "telegram-theme-build": runTelegramThemeBuildScenario, "auth-recovery": runAuthRecoveryScenario, }; const scenario = scenarios[scenarioName]; @@ -633,6 +643,1228 @@ async function runAuthBootstrapScenario() { await Promise.all([app.stop(), cancelledApp.stop()]); } +async function runTelegramBootstrapScenario() { + const { bootSalvoApp } = await import("../src/app.js"); + const authResponse = deferred(); + const sessionToken = "t".repeat(43); + const user = telegramUser("mini-app-user", "Mini App Captain"); + const harness = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + miniAppResponse: () => authResponse.promise, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + + const app = bootSalvoApp(harness.dependencies); + await waitFor(() => harness.fetchCalls.some(({ url }) => ( + url.endsWith("/auth/telegram/miniapp") + ))); + assert.ok(harness.calls.networkSamples >= 1, "auth waits for the first runtime network sample"); + const authRequest = harness.fetchCalls.find(({ url }) => ( + url.endsWith("/auth/telegram/miniapp") + )); + assert.deepEqual(JSON.parse(authRequest.init.body), { initData: "signed-init-data" }); + assert.equal(harness.fetchCalls.some(({ url }) => url.endsWith("/auth/telegram/config")), false); + + authResponse.resolve({ token: sessionToken, user }); + await app.startup.authReady; + assert.equal(app.getState().auth.user.id, user.id); + assert.equal(app.getState().auth.token, sessionToken); + assert.equal(harness.calls.secureSets, 1); + assert.doesNotMatch(harness.root.innerHTML, /auth-telegram-oidc|telegram-login-slot/); + await app.stop(); +} + +async function runTelegramLaunchRoutingScenario() { + const { bootSalvoApp } = await import("../src/app.js"); + const sessionToken = "l".repeat(43); + const user = telegramUser("launch-user", "Launch Captain"); + const authResponse = deferred(); + const joinedRooms = []; + const roomHarness = createAppHarness({ + platformName: "telegram", + launchData: "signed-room-init-data", + startParam: "room_ABCD", + miniAppResponse: () => authResponse.promise, + createRemoteClient() { + return remoteClientHarness({ + async joinRoom(roomCode) { + joinedRooms.push(roomCode); + return { + roomCode, + playerId: "p2", + playerToken: "private-player-token", + presetId: "classic", + }; + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const roomApp = bootSalvoApp(roomHarness.dependencies); + await waitFor(() => roomHarness.fetchCalls.some(({ url }) => ( + url.endsWith("/auth/telegram/miniapp") + ))); + assert.equal(roomApp.getState().screen, "menu"); + assert.deepEqual(joinedRooms, []); + + authResponse.resolve({ token: sessionToken, user }); + await roomApp.startup.done; + assert.equal(roomApp.getState().screen, "online"); + assert.equal(roomApp.getState().online.roomCodeInput, "ABCD"); + assert.deepEqual(joinedRooms, ["ABCD"]); + assert.equal(roomApp.getState().online.session.roomCode, "ABCD"); + + await roomHarness.root.click("auth-telegram-retry"); + assert.deepEqual(joinedRooms, ["ABCD"], "launch is not replayed after explicit authentication"); + + const replayAuth = deferred(); + const replayHarness = createAppHarness({ + platformName: "telegram", + launchData: "signed-replay-init-data", + startParam: "replay_replay-123", + miniAppResponse: () => replayAuth.promise, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + if (url.endsWith("/replays/replay-123")) { + return response({ error: "Replay not found" }, { ok: false, status: 404 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const replayApp = bootSalvoApp(replayHarness.dependencies); + await waitFor(() => replayHarness.fetchCalls.some(({ url }) => ( + url.endsWith("/auth/telegram/miniapp") + ))); + assert.equal( + replayHarness.fetchCalls.some(({ url }) => url.includes("/replays/")), + false, + "private replay must not load before authentication", + ); + + replayAuth.resolve({ token: sessionToken, user }); + await replayApp.startup.done; + const replayRequest = replayHarness.fetchCalls.find(({ url }) => ( + url.endsWith("/replays/replay-123") + )); + assert.ok(replayRequest); + assert.equal(replayRequest.init.headers.Authorization, `Bearer ${sessionToken}`); + assert.equal(replayApp.getState().screen, "replay"); + assert.equal(replayApp.getState().replayArchive.requestedId, "replay-123"); + + for (const startParam of [ + "room_abcd", + `room_${"A".repeat(13)}`, + "replay_bad_id", + "replay_bad/id", + ]) { + let clientsCreated = 0; + const invalid = createAppHarness({ + platformName: "telegram", + launchData: "signed-invalid-init-data", + startParam, + createRemoteClient() { + clientsCreated += 1; + return remoteClientHarness(); + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch for ${startParam}: ${url}`); + }, + }); + const invalidApp = bootSalvoApp(invalid.dependencies); + await invalidApp.startup.done; + assert.equal(invalidApp.getState().screen, "menu", startParam); + assert.equal(clientsCreated, 0, startParam); + assert.equal(invalid.fetchCalls.some(({ url }) => url.includes("/replays/")), false, startParam); + await invalidApp.stop(); + } + + const roomUnavailableCopy = { + ru: "Комната заполнена, закрыта или недоступна. Вернитесь в онлайн-лобби и выберите другую комнату.", + "zh-CN": "此房间已满、已关闭或不可用。请返回在线大厅并尝试其他房间。", + }; + const miniAppAccountCopy = { + ru: "Аккаунт Telegram Mini App подтверждён. Ваш существующий профиль и онлайн-прогресс доступны.", + "zh-CN": "Telegram Mini App 账号已确认。您可以继续使用现有档案和在线进度。", + }; + for (const [language, failureMessage, expectedMessage, expectedKey] of [ + ["ru", "Room is full", roomUnavailableCopy.ru, "online.roomUnavailable"], + ["zh-CN", "Room not found", roomUnavailableCopy["zh-CN"], "online.roomUnavailable"], + ["ru", "Room is closed", roomUnavailableCopy.ru, "online.roomUnavailable"], + ["zh-CN", "Room is unavailable", roomUnavailableCopy["zh-CN"], "online.roomUnavailable"], + ["ru", "Room connection unavailable", "Room connection unavailable", ""], + ]) { + const failedJoin = createAppHarness({ + platformName: "telegram", + launchData: "signed-failed-room-init-data", + startParam: "room_ABCD", + preferences: resolvedDeferred(language), + createRemoteClient() { + return remoteClientHarness({ + async joinRoom() { + throw new Error(failureMessage); + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const failedJoinApp = bootSalvoApp(failedJoin.dependencies); + await failedJoinApp.startup.done; + assert.equal(failedJoinApp.getState().screen, "online", failureMessage); + assert.equal(failedJoinApp.getState().language, language, failureMessage); + assert.equal(failedJoinApp.getState().online.roomCodeInput, "ABCD", failureMessage); + assert.equal(failedJoinApp.getState().online.session, null, failureMessage); + assert.equal(failedJoinApp.getState().online.error, failureMessage, failureMessage); + assert.equal(failedJoinApp.getState().online.errorKey, expectedKey, failureMessage); + assert.ok(failedJoin.root.innerHTML.includes(expectedMessage), failureMessage); + assert.ok(failedJoin.root.innerHTML.includes(miniAppAccountCopy[language]), failureMessage); + if (expectedMessage !== failureMessage) { + assert.equal(failedJoin.root.innerHTML.includes(failureMessage), false, failureMessage); + } + if (failureMessage === "Room is full") { + assert.match(failedJoin.root.innerHTML, /class="error-line" role="alert" aria-live="assertive"/); + await failedJoin.root.change("language", { value: "zh-CN" }); + assert.equal(failedJoinApp.getState().online.error, failureMessage); + assert.equal(failedJoinApp.getState().online.errorKey, "online.roomUnavailable"); + assert.ok(failedJoin.root.innerHTML.includes(roomUnavailableCopy["zh-CN"])); + assert.equal(failedJoin.root.innerHTML.includes(roomUnavailableCopy.ru), false); + } + await failedJoinApp.stop(); + } + + const webFailureMessage = "Room is full"; + const webFailure = createAppHarness({ + secureSession: resolvedDeferred("web-session-token"), + createRemoteClient() { + return remoteClientHarness({ + async joinRoom() { + throw new Error(webFailureMessage); + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/auth/me")) { + return response({ user: telegramUser("web-room-user", "Web Room Captain") }); + } + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const webFailureApp = bootSalvoApp(webFailure.dependencies); + await webFailureApp.startup.done; + await webFailure.root.click("show-online"); + await webFailure.root.change("room-code", { value: "ABCD" }); + await webFailure.root.click("online-join"); + assert.equal(webFailureApp.getState().online.error, webFailureMessage); + assert.match(webFailure.root.innerHTML, /Telegram confirmed\. Online results are saved to your profile\./); + assert.doesNotMatch(webFailure.root.innerHTML, /Telegram Mini App account confirmed/); + await webFailureApp.stop(); + + const guardedAuth = deferred(); + const guardedJoins = []; + const guarded = createAppHarness({ + platformName: "telegram", + launchData: "signed-guarded-init-data", + startParam: "room_GUARD", + miniAppResponse: () => guardedAuth.promise, + createRemoteClient() { + return remoteClientHarness({ + async joinRoom(roomCode) { + guardedJoins.push(roomCode); + return { roomCode, playerId: "p2", playerToken: "private-token", presetId: "classic" }; + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const guardedApp = bootSalvoApp(guarded.dependencies); + await guarded.root.click("start-agent"); + guardedAuth.resolve({ token: sessionToken, user }); + await guardedApp.startup.done; + assert.equal(guardedApp.getState().screen, "setup"); + assert.equal(guardedApp.getState().leaveBattleDialog, true); + assert.deepEqual(guardedJoins, []); + await guarded.root.click("confirm-leave-battle"); + assert.equal(guardedApp.getState().screen, "online"); + assert.deepEqual(guardedJoins, ["GUARD"]); + + await Promise.all([roomApp.stop(), replayApp.stop(), guardedApp.stop()]); +} + +async function runTelegramLaunchRetryScenario() { + const { bootSalvoApp } = await import("../src/app.js"); + const sessionToken = "r".repeat(43); + const user = telegramUser("retry-launch-user", "Retry Launch Captain"); + let roomAuthAttempts = 0; + const joinedRooms = []; + const room = createAppHarness({ + platformName: "telegram", + launchData: "signed-room-retry-init-data", + startParam: "room_RETRY", + miniAppResponse() { + roomAuthAttempts += 1; + return roomAuthAttempts === 1 + ? miniAppServiceFailure() + : { token: sessionToken, user }; + }, + createRemoteClient() { + return remoteClientHarness({ + async joinRoom(roomCode) { + joinedRooms.push(roomCode); + return { roomCode, playerId: "p2", playerToken: "private-token", presetId: "classic" }; + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const roomApp = bootSalvoApp(room.dependencies); + await roomApp.startup.done; + assert.equal(roomApp.getState().screen, "menu"); + assert.deepEqual(joinedRooms, []); + assert.equal(roomAuthAttempts, 1); + + await room.root.click("auth-telegram-retry"); + assert.equal(roomApp.getState().screen, "online"); + assert.equal(roomApp.getState().online.roomCodeInput, "RETRY"); + assert.deepEqual(joinedRooms, ["RETRY"]); + assert.equal(roomAuthAttempts, 2); + await room.root.click("theme-toggle"); + await room.root.click("auth-telegram-retry"); + assert.deepEqual(joinedRooms, ["RETRY"]); + assert.equal(roomAuthAttempts, 2, "an authenticated retry is ignored"); + + let replayAuthAttempts = 0; + const replay = createAppHarness({ + platformName: "telegram", + launchData: "signed-replay-retry-init-data", + startParam: "replay_retry-replay", + miniAppResponse() { + replayAuthAttempts += 1; + return replayAuthAttempts === 1 + ? miniAppServiceFailure() + : { token: sessionToken, user }; + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + if (url.endsWith("/replays/retry-replay")) { + return response({ error: "Replay not found" }, { ok: false, status: 404 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const replayApp = bootSalvoApp(replay.dependencies); + await replayApp.startup.done; + assert.equal(replayApp.getState().screen, "menu"); + assert.equal(replay.fetchCalls.some(({ url }) => url.includes("/replays/")), false); + + await replay.root.click("auth-telegram-retry"); + assert.equal(replayApp.getState().screen, "replay"); + assert.equal(replayApp.getState().replayArchive.requestedId, "retry-replay"); + assert.equal(replay.fetchCalls.filter(({ url }) => url.endsWith("/replays/retry-replay")).length, 1); + await replay.root.click("theme-toggle"); + await replay.root.click("auth-telegram-retry"); + assert.equal(replayAuthAttempts, 2, "an authenticated retry is ignored"); + assert.equal(replay.fetchCalls.filter(({ url }) => url.endsWith("/replays/retry-replay")).length, 1); + + await Promise.all([roomApp.stop(), replayApp.stop()]); +} + +async function runTelegramLaunchAuthorityScenario() { + const { bootSalvoApp } = await import("../src/app.js"); + const replayHarness = ({ initialReplayId, startParam, platformName = "telegram" }) => ( + createAppHarness({ + platformName, + launchData: platformName === "telegram" ? "signed-authority-init-data" : "", + startParam, + initialUrl: `https://agent-axiom.github.io/agents-salvo/?replay=${initialReplayId}`, + secureSession: platformName === "telegram" ? resolvedDeferred("") : resolvedDeferred("web-token"), + fetchResponse(url) { + if (url.endsWith("/auth/me")) { + return response({ user: { id: "web-user", name: "Web Captain", username: "web" } }); + } + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + if (url.includes("/replays/")) { + return response({ error: "Replay not found" }, { ok: false, status: 404 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }, + }) + ); + const replayRequestIds = (harness) => harness.fetchCalls + .filter(({ url }) => url.includes("/replays/")) + .map(({ url }) => decodeURIComponent(url.split("/replays/")[1])); + + const same = replayHarness({ + initialReplayId: "same-replay", + startParam: "replay_same-replay", + }); + const sameApp = bootSalvoApp(same.dependencies); + await sameApp.startup.done; + assert.deepEqual(replayRequestIds(same), ["same-replay"]); + assert.equal(sameApp.getState().replayArchive.requestedId, "same-replay"); + assert.equal(same.calls.historyPushes, 0); + assert.equal(same.calls.historyReplacements, 0); + + const conflict = replayHarness({ + initialReplayId: "url-replay", + startParam: "replay_start-replay", + }); + const conflictApp = bootSalvoApp(conflict.dependencies); + await conflictApp.startup.done; + assert.deepEqual(replayRequestIds(conflict), ["start-replay"]); + assert.equal(conflictApp.getState().replayArchive.requestedId, "start-replay"); + assert.equal(conflict.dependencies.window.location.search, "?replay=start-replay"); + assert.equal(conflict.calls.historyPushes, 0); + assert.equal(conflict.calls.historyReplacements, 1); + + const invalid = replayHarness({ + initialReplayId: "url-replay", + startParam: "replay_bad_id", + }); + const invalidApp = bootSalvoApp(invalid.dependencies); + await invalidApp.startup.done; + assert.deepEqual(replayRequestIds(invalid), ["url-replay"]); + assert.equal(invalidApp.getState().replayArchive.requestedId, "url-replay"); + assert.equal(invalid.calls.historyPushes, 0); + assert.equal(invalid.calls.historyReplacements, 0); + + const web = replayHarness({ + initialReplayId: "web-replay", + startParam: "replay_ignored-in-web", + platformName: "web", + }); + const webApp = bootSalvoApp(web.dependencies); + await webApp.startup.done; + assert.deepEqual(replayRequestIds(web), ["web-replay"]); + assert.equal(webApp.getState().replayArchive.requestedId, "web-replay"); + + await Promise.all([sameApp.stop(), conflictApp.stop(), invalidApp.stop(), webApp.stop()]); +} + +async function runTelegramLaunchSharingScenario() { + const { bootSalvoApp } = await import("../src/app.js"); + const telegram = createAppHarness({ + platformName: "telegram", + launchData: "signed-room-init-data", + startParam: "room_ABCD", + shareResult: { shared: true }, + createRemoteClient() { + return remoteClientHarness({ + async joinRoom(roomCode) { + return { roomCode, playerId: "p2", playerToken: "private-token", presetId: "classic" }; + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const telegramApp = bootSalvoApp(telegram.dependencies); + await telegramApp.startup.done; + await telegram.root.click("share-telegram"); + assert.equal(telegram.calls.sharePayloads.length, 1); + assert.deepEqual(telegram.calls.sharePayloads[0], { + title: "Salvo", + text: "Join my Salvo room: ABCD", + url: "https://t.me/salvo_test_bot?startapp=room_ABCD", + }); + assert.equal(telegramApp.getState().online.status, ""); + assert.doesNotMatch(telegram.root.innerHTML, /invite link copied/i); + + const telegramReplay = createAppHarness({ + platformName: "telegram", + launchData: "signed-replay-share-init-data", + startParam: "replay_replay-123", + shareResult: { shared: true }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + if (url.endsWith("/replays/replay-123")) { + return response({ replay: archivedReplayFixture("replay-123") }); + } + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const telegramReplayApp = bootSalvoApp(telegramReplay.dependencies); + await telegramReplayApp.startup.done; + assert.match(telegramReplay.root.innerHTML, />Share in Telegram<\/button>/); + assert.doesNotMatch(telegramReplay.root.innerHTML, />Copy link<\/button>/); + await telegramReplay.root.click("replay-copy-link"); + assert.equal(telegramReplay.calls.sharePayloads.length, 1); + assert.equal( + telegramReplay.calls.sharePayloads[0].url, + "https://t.me/salvo_test_bot?startapp=replay_replay-123", + ); + assert.equal(telegramReplay.calls.sharePayloads[0].text, "Battle replay"); + assert.equal(telegramReplayApp.getState().replayArchive.copyStatus, ""); + assert.doesNotMatch(telegramReplay.root.innerHTML, /Replay link copied/); + + const copiedTelegramReplay = createAppHarness({ + platformName: "telegram", + launchData: "signed-copied-replay-share-init-data", + startParam: "replay_replay-copied", + shareResult: { shared: false, copied: true }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + if (url.endsWith("/replays/replay-copied")) { + return response({ replay: archivedReplayFixture("replay-copied") }); + } + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const copiedTelegramReplayApp = bootSalvoApp(copiedTelegramReplay.dependencies); + await copiedTelegramReplayApp.startup.done; + await copiedTelegramReplay.root.click("replay-copy-link"); + assert.equal(copiedTelegramReplayApp.getState().replayArchive.copyStatus, "copied"); + assert.match(copiedTelegramReplay.root.innerHTML, /Replay link copied/); + assert.doesNotMatch(copiedTelegramReplay.root.innerHTML, /Could not share/); + + const failedTelegramReplay = createAppHarness({ + platformName: "telegram", + launchData: "signed-failed-replay-share-init-data", + startParam: "replay_replay-456", + shareResult: { shared: false }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + if (url.endsWith("/replays/replay-456")) { + return response({ replay: archivedReplayFixture("replay-456") }); + } + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const failedTelegramReplayApp = bootSalvoApp(failedTelegramReplay.dependencies); + await failedTelegramReplayApp.startup.done; + assert.match(failedTelegramReplay.root.innerHTML, />Share in Telegram<\/button>/); + await failedTelegramReplay.root.click("replay-copy-link"); + assert.equal(failedTelegramReplayApp.getState().replayArchive.copyStatus, "error"); + assert.match(failedTelegramReplay.root.innerHTML, /Could not share\./); + assert.doesNotMatch(failedTelegramReplay.root.innerHTML, /Could not copy the replay link/); + + const failed = createAppHarness({ + platformName: "telegram", + launchData: "signed-room-init-data", + startParam: "room_ABCD", + shareResult: { shared: false }, + createRemoteClient() { + return remoteClientHarness({ + async joinRoom(roomCode) { + return { roomCode, playerId: "p2", playerToken: "private-token", presetId: "classic" }; + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const failedApp = bootSalvoApp(failed.dependencies); + await failedApp.startup.done; + await failed.root.click("share-telegram"); + assert.equal(failedApp.getState().online.error, ""); + assert.equal(failedApp.getState().online.shareStatus, "share-failed"); + assert.match(failed.root.innerHTML, /Could not share\./); + assert.deepEqual(failed.calls.openedUrls, [], "failed Telegram sharing must remain failed"); + + const copiedRoom = createAppHarness({ + platformName: "telegram", + launchData: "signed-copied-room-share-init-data", + startParam: "room_COPY", + shareResult: { shared: false, copied: true }, + createRemoteClient() { + return remoteClientHarness({ + async joinRoom(roomCode) { + return { roomCode, playerId: "p2", playerToken: "private-token", presetId: "classic" }; + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const copiedRoomApp = bootSalvoApp(copiedRoom.dependencies); + await copiedRoomApp.startup.done; + await copiedRoom.root.click("share-telegram"); + assert.equal(copiedRoomApp.getState().online.status, ""); + assert.equal(copiedRoomApp.getState().online.shareStatus, "invite-copied"); + assert.equal(copiedRoomApp.getState().online.error, ""); + assert.match(copiedRoom.root.innerHTML, /Room invite link copied/); + assert.doesNotMatch(copiedRoom.root.innerHTML, /Could not share/); + + const web = createAppHarness({ + secureSession: resolvedDeferred("web-session-token"), + shareResult: { shared: true }, + createRemoteClient() { + return remoteClientHarness({ + async createRoom() { + return { roomCode: "WEB1", playerId: "p1", playerToken: "private-token" }; + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/auth/me")) { + return response({ user: { id: "web-user", name: "Web Captain", username: "web" } }); + } + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const webApp = bootSalvoApp(web.dependencies); + await webApp.startup.done; + await web.root.click("show-online"); + await web.root.click("online-create"); + await web.root.click("share-telegram"); + assert.equal(web.calls.sharePayloads[0].url, "https://agent-axiom.github.io/agents-salvo/"); + + const webReplay = createAppHarness({ + initialUrl: "https://agent-axiom.github.io/agents-salvo/?replay=replay-789", + secureSession: resolvedDeferred("web-session-token"), + fetchResponse(url) { + if (url.endsWith("/auth/me")) { + return response({ user: { id: "web-user", name: "Web Captain", username: "web" } }); + } + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + if (url.endsWith("/replays/replay-789")) { + return response({ replay: archivedReplayFixture("replay-789") }); + } + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const webReplayApp = bootSalvoApp(webReplay.dependencies); + await webReplayApp.startup.done; + assert.match(webReplay.root.innerHTML, />Copy link<\/button>/); + assert.doesNotMatch(webReplay.root.innerHTML, />Share in Telegram<\/button>/); + await webReplay.root.click("replay-copy-link"); + assert.equal( + webReplay.calls.clipboardWrites.at(-1), + "https://agent-axiom.github.io/agents-salvo/?replay=replay-789", + ); + assert.equal(webReplayApp.getState().replayArchive.copyStatus, "copied"); + assert.match(webReplay.root.innerHTML, /Replay link copied/); + + await Promise.all([ + telegramApp.stop(), + telegramReplayApp.stop(), + copiedTelegramReplayApp.stop(), + failedTelegramReplayApp.stop(), + failedApp.stop(), + copiedRoomApp.stop(), + webApp.stop(), + webReplayApp.stop(), + ]); +} + +async function runTelegramShareStatusRaceScenario() { + const { bootSalvoApp } = await import("../src/app.js"); + for (const [name, roomCode, outcome, expectedShareStatus, feedbackPattern, feedbackRole] of [ + ["copied", "COPY", { shared: false, copied: true }, "invite-copied", /Room invite link copied/, "status"], + ["failed", "FAIL", { shared: false, copied: false }, "share-failed", /Could not share\./, "alert"], + ]) { + const shareResult = deferred(); + let remoteHandlers = null; + const harness = createAppHarness({ + platformName: "telegram", + launchData: `signed-${name}-share-race-init-data`, + startParam: `room_${roomCode}`, + shareResult: shareResult.promise, + createRemoteClient(handlers) { + remoteHandlers = handlers; + return remoteClientHarness({ + async joinRoom(joinedRoomCode) { + return { + roomCode: joinedRoomCode, + playerId: "p2", + playerToken: "private-token", + presetId: "classic", + }; + }, + }); + }, + fetchResponse(url) { + if (url.endsWith("/profile/me")) return response({ profile: { leaderboard: [] } }); + if (url.endsWith("/leaderboard")) return response({ leaderboard: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }, + }); + const app = bootSalvoApp(harness.dependencies); + await app.startup.done; + assert.ok(remoteHandlers, name); + + const sharing = harness.root.click("share-telegram"); + await waitFor(() => harness.calls.sharePayloads.length === 1); + remoteHandlers.onStatus("disconnected"); + remoteHandlers.onError(new Error("connection failed")); + assert.equal(app.getState().online.status, "disconnected", name); + assert.equal(app.getState().online.error, "connection failed", name); + + shareResult.resolve(outcome); + await sharing; + assert.equal(app.getState().online.status, "disconnected", name); + assert.equal(app.getState().online.error, "connection failed", name); + assert.equal(app.getState().online.shareStatus, expectedShareStatus, name); + assert.match(harness.root.innerHTML, /Disconnected/, name); + assert.match(harness.root.innerHTML, /connection failed/, name); + assert.match(harness.root.innerHTML, feedbackPattern, name); + assert.match( + harness.root.innerHTML, + new RegExp(`class="status-line online-share-status[^"]*" role="${feedbackRole}"`), + name, + ); + + await app.stop(); + } +} + +async function runTelegramAuthRecoveryScenario() { + const { bootSalvoApp } = await import("../src/app.js"); + for (const [name, options] of [ + ["missing SDK", { platformAvailable: false, launchData: "signed-init-data" }], + ["missing initData", { platformAvailable: true, launchData: "" }], + ["missing client", { platformAvailable: true, launchData: "signed-init-data", workerUrl: "" }], + ]) { + const harness = createAppHarness({ + platformName: "telegram", + ...options, + }); + const app = bootSalvoApp(harness.dependencies); + await app.startup.done; + assert.equal(app.getState().auth.method, "miniapp-unavailable", name); + assert.equal(app.getState().auth.token, "", name); + assert.equal(app.getState().auth.user, null, name); + assert.match(harness.root.innerHTML, /Open Salvo in Telegram to sign in/, name); + assert.match(harness.root.innerHTML, /data-action="auth-miniapp-open"/, name); + assert.doesNotMatch(harness.root.innerHTML, /data-action="auth-telegram-retry"/, name); + await harness.root.click("auth-miniapp-open"); + assert.deepEqual(harness.calls.openedUrls, ["https://t.me/salvo_test_bot?startapp"], name); + assert.equal( + harness.fetchCalls.some(({ url }) => url.endsWith("/auth/telegram/miniapp")), + false, + name, + ); + assert.doesNotMatch(harness.root.innerHTML, /data-action="start-agent"[^>]*disabled/, name); + assert.doesNotMatch(harness.root.innerHTML, /data-action="start-hotseat"[^>]*disabled/, name); + assert.doesNotMatch(harness.root.innerHTML, /data-action="start-training"[^>]*disabled/, name); + assert.doesNotMatch(harness.root.innerHTML, /data-action="toggle-profile"/, name); + await harness.root.click("show-online"); + assert.match(harness.root.innerHTML, /data-action="online-create"[^>]*disabled/, name); + assert.match(harness.root.innerHTML, /data-action="online-join"[^>]*disabled/, name); + await app.stop(); + } + + const invalidBot = createAppHarness({ + platformName: "telegram", + platformAvailable: false, + telegramBotUsername: "bad/name", + }); + const invalidBotApp = bootSalvoApp(invalidBot.dependencies); + await invalidBotApp.startup.done; + assert.match(invalidBot.root.innerHTML, /Open Salvo in Telegram to sign in/); + assert.doesNotMatch(invalidBot.root.innerHTML, /auth-miniapp-open|https:\/\/t\.me/); + assert.deepEqual(invalidBot.calls.openedUrls, []); + + const authenticationFailure = { error: "Telegram Mini App authentication failed" }; + const expiredApps = []; + for (const [name, launchData] of [ + ["stale initData", "stale-init-data"], + ["tampered initData", "tampered-init-data"], + ]) { + const expired = createAppHarness({ + platformName: "telegram", + launchData, + miniAppResponse: new Response(JSON.stringify(authenticationFailure), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + }); + const expiredApp = bootSalvoApp(expired.dependencies); + expiredApps.push(expiredApp); + await expiredApp.startup.done; + assert.equal(expiredApp.getState().auth.method, "miniapp-expired", name); + assert.equal(expiredApp.getState().auth.token, "", name); + assert.equal(expiredApp.getState().auth.user, null, name); + assert.match(expired.root.innerHTML, /Telegram Mini App session expired/, name); + assert.doesNotMatch(expired.root.innerHTML, /authentication failed|auth-telegram-retry/, name); + assert.match(expired.root.innerHTML, /data-action="auth-miniapp-reopen"/, name); + await expired.root.click("auth-miniapp-reopen"); + assert.deepEqual(expired.calls.openedUrls, ["https://t.me/salvo_test_bot?startapp"], name); + } + + for (const [name, startParam, expectedUrl] of [ + ["room launch", "room_REOPEN", "https://t.me/salvo_test_bot?startapp=room_REOPEN"], + ["replay launch", "replay_reopen-123", "https://t.me/salvo_test_bot?startapp=replay_reopen-123"], + ["invalid launch", "room_reopen", "https://t.me/salvo_test_bot?startapp"], + ]) { + const expired = createAppHarness({ + platformName: "telegram", + launchData: `expired-${name}`, + startParam, + miniAppResponse: new Response(JSON.stringify(authenticationFailure), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + }); + const expiredApp = bootSalvoApp(expired.dependencies); + expiredApps.push(expiredApp); + await expiredApp.startup.done; + assert.equal(expiredApp.getState().auth.method, "miniapp-expired", name); + await expired.root.click("auth-miniapp-reopen"); + assert.deepEqual(expired.calls.openedUrls, [expectedUrl], name); + } + + const serviceFailureApps = []; + for (const name of ["Worker configuration failure", "D1 session failure"]) { + const serviceFailure = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + miniAppResponse: new Response(JSON.stringify(authenticationFailure), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + }); + const serviceFailureApp = bootSalvoApp(serviceFailure.dependencies); + serviceFailureApps.push(serviceFailureApp); + await serviceFailureApp.startup.done; + assert.equal(serviceFailureApp.getState().auth.method, "miniapp", name); + assert.equal(serviceFailureApp.getState().auth.token, "", name); + assert.equal(serviceFailureApp.getState().auth.user, null, name); + assert.match(serviceFailure.root.innerHTML, /Telegram login is unavailable/, name); + assert.doesNotMatch(serviceFailure.root.innerHTML, /authentication failed|auth-miniapp-reopen/, name); + assert.match(serviceFailure.root.innerHTML, /data-action="auth-telegram-retry"/, name); + assert.doesNotMatch(serviceFailure.root.innerHTML, /data-action="start-agent"[^>]*disabled/, name); + assert.doesNotMatch(serviceFailure.root.innerHTML, /data-action="start-hotseat"[^>]*disabled/, name); + assert.doesNotMatch(serviceFailure.root.innerHTML, /data-action="start-training"[^>]*disabled/, name); + await serviceFailure.root.click("show-online"); + assert.match(serviceFailure.root.innerHTML, /data-action="online-create"[^>]*disabled/, name); + assert.match(serviceFailure.root.innerHTML, /data-action="online-join"[^>]*disabled/, name); + } + + let attempts = 0; + const retryToken = "r".repeat(43); + const retry = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + miniAppResponse() { + attempts += 1; + if (attempts === 1) return new Error("private provider detail"); + return { token: retryToken, user: telegramUser("retry-user", "Retry Captain") }; + }, + }); + const retryApp = bootSalvoApp(retry.dependencies); + await retryApp.startup.done; + assert.equal(retryApp.getState().auth.method, "miniapp"); + assert.equal(retryApp.getState().auth.token, ""); + assert.equal(retryApp.getState().auth.user, null); + assert.match(retry.root.innerHTML, /Telegram login is unavailable/); + assert.doesNotMatch(retry.root.innerHTML, /private provider detail/); + assert.match(retry.root.innerHTML, /data-action="auth-telegram-retry"/); + + await retry.root.click("auth-telegram-retry"); + assert.equal(attempts, 2); + assert.equal(retryApp.getState().auth.token, retryToken); + assert.equal(retryApp.getState().auth.user.id, "retry-user"); + assert.equal(retry.calls.secureSets, 1); + + await retry.root.click("auth-logout"); + await flushMicrotasks(); + assert.equal(attempts, 2, "logout must not trigger an automatic reauth loop"); + assert.equal(retryApp.getState().auth.token, ""); + assert.equal(retryApp.getState().auth.user, null); + await retry.root.click("auth-telegram-retry"); + assert.equal(attempts, 3, "an explicit retry may authenticate after logout"); + assert.equal(retryApp.getState().auth.token, retryToken); + + const staleResponse = deferred(); + const stale = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + miniAppResponse: () => staleResponse.promise, + }); + const staleApp = bootSalvoApp(stale.dependencies); + await waitFor(() => stale.fetchCalls.some(({ url }) => url.endsWith("/auth/telegram/miniapp"))); + await stale.root.click("auth-logout"); + staleResponse.resolve({ + token: "s".repeat(43), + user: telegramUser("stale-user", "Stale Captain"), + }); + await staleApp.startup.done; + assert.equal(staleApp.getState().auth.token, ""); + assert.equal(staleApp.getState().auth.user, null); + assert.equal(stale.calls.secureSets, 0); + + const persistence = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + miniAppResponse: { + token: "p".repeat(43), + user: telegramUser("storage-user", "Storage Captain"), + }, + onSecureSet: async () => Promise.reject(new Error("memory write failed")), + }); + const persistenceApp = bootSalvoApp(persistence.dependencies); + await persistenceApp.startup.done; + assert.equal(persistenceApp.getState().auth.token, ""); + assert.equal(persistenceApp.getState().auth.user, null); + assert.match(persistence.root.innerHTML, /Secure login could not be saved/); + assert.doesNotMatch(persistence.root.innerHTML, /memory write failed/); + + await Promise.all([ + invalidBotApp.stop(), + ...expiredApps.map((app) => app.stop()), + ...serviceFailureApps.map((app) => app.stop()), + retryApp.stop(), + staleApp.stop(), + persistenceApp.stop(), + ]); +} + +async function runTelegramRuntimeScenario() { + const { bootSalvoApp } = await import("../src/app.js"); + const harness = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + }); + const app = bootSalvoApp(harness.dependencies); + await app.startup.done; + + assert.equal(harness.calls.ready, 1); + assert.deepEqual(harness.calls.backButtonVisibility, [false]); + assert.deepEqual(harness.activePlatformHandlers(), { + back: true, + deepLink: true, + lifecycle: true, + network: true, + settings: true, + theme: true, + viewport: true, + }); + assert.equal(harness.calls.closingConfirmations.at(-1), false); + + await harness.emitSettings(); + assert.equal(app.getState().settingsOpen, true); + assert.equal(harness.calls.backButtonVisibility.at(-1), true); + await harness.emitBack(); + assert.equal(app.getState().settingsOpen, false); + assert.equal(harness.calls.backButtonVisibility.at(-1), false); + + await harness.root.click("start-agent"); + assert.equal(harness.calls.backButtonVisibility.at(-1), true); + assert.equal(harness.calls.closingConfirmations.at(-1), true); + await harness.root.click("toggle-leaderboard"); + await harness.root.click("menu"); + assert.equal(app.getState().leaveBattleDialog, true); + assert.equal(app.getState().leaderboardOpen, true); + + await harness.emitBack(); + assert.equal(app.getState().leaveBattleDialog, false, "active leave dialog closes first"); + assert.equal(app.getState().leaderboardOpen, true, "overlapped leaderboard remains open"); + await harness.emitBack(); + assert.equal(app.getState().leaderboardOpen, false); + + await harness.root.click("ready"); + assert.equal(app.getState().screen, "playing"); + await harness.root.click("menu"); + assert.equal(app.getState().leaveBattleDialog, true); + app.getState().game.phase = "finished"; + app.getState().game.winnerId = "p1"; + await harness.root.click("toggle-settings"); + assert.match(harness.root.innerHTML, /data-action="close-result"/); + assert.equal(app.getState().settingsOpen, true); + + await harness.emitBack(); + assert.equal(app.getState().leaveBattleDialog, false, "leave dialog closes before result"); + assert.equal(app.getState().resultModalDismissed, null, "overlapped result remains open"); + assert.equal(app.getState().settingsOpen, true, "overlapped settings remain open"); + await harness.emitBack(); + assert.notEqual(app.getState().resultModalDismissed, null, "result closes before settings"); + assert.equal(app.getState().settingsOpen, true, "overlapped settings remain open"); + await harness.emitBack(); + assert.equal(app.getState().settingsOpen, false); + + assert.equal(app.getState().tacticalAdvisorOpen, true); + await harness.emitBack(); + assert.equal(app.getState().tacticalAdvisorOpen, false, "visible tactical coaching collapses"); + assert.equal(app.getState().screen, "playing"); + await harness.emitBack(); + assert.equal(app.getState().screen, "menu"); + assert.equal(harness.calls.backButtonVisibility.at(-1), false); + assert.equal(harness.calls.closingConfirmations.at(-1), false); + + await harness.emitLifecycle({ active: false }); + await harness.emitLifecycle({ active: true }); + assert.equal(harness.calls.audioPauses, 1); + assert.equal(harness.calls.audioResumes, 1); + + await harness.root.click("toggle-settings"); + await harness.root.click("toggle-settings"); + assert.equal(harness.calls.ready, 1, "rerenders must not repeat platform.ready()"); + + await app.stop(); + assert.deepEqual(harness.activePlatformHandlers(), { + back: false, + deepLink: false, + lifecycle: false, + network: false, + settings: false, + theme: false, + viewport: false, + }); + + let visibilityAttempts = 0; + const retryVisibility = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + onSetBackButtonVisible: async () => { + visibilityAttempts += 1; + if (visibilityAttempts === 1) { + throw new Error("Telegram button visibility update failed"); + } + }, + }); + const retryVisibilityApp = bootSalvoApp(retryVisibility.dependencies); + await retryVisibilityApp.startup.done; + await flushMicrotasks(); + await retryVisibility.root.click("theme-toggle"); + await flushMicrotasks(); + assert.deepEqual(retryVisibility.calls.backButtonVisibility, [false, false]); + assert.doesNotMatch(retryVisibility.root.innerHTML, /provider|visibility update failed/); + await retryVisibilityApp.stop(); + + const releaseEnable = deferred(); + const providerClosingStates = []; + const serializedClosing = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + onSetClosingConfirmation: async (enabled) => { + if (enabled) await releaseEnable.promise; + providerClosingStates.push(enabled); + }, + }); + const serializedClosingApp = bootSalvoApp(serializedClosing.dependencies); + await serializedClosingApp.startup.done; + await flushMicrotasks(); + assert.deepEqual(providerClosingStates, [false]); + + await serializedClosing.root.click("start-agent"); + await serializedClosing.root.click("ready"); + await flushMicrotasks(); + assert.deepEqual(serializedClosing.calls.closingConfirmations, [false, true]); + serializedClosingApp.getState().game.phase = "finished"; + serializedClosingApp.getState().game.winnerId = "p1"; + await serializedClosing.root.click("menu"); + assert.equal(serializedClosingApp.getState().screen, "menu"); + assert.deepEqual( + serializedClosing.calls.closingConfirmations, + [false, true], + "disable waits for the in-flight enable", + ); + + releaseEnable.resolve(); + await flushMicrotasks(); + assert.deepEqual(serializedClosing.calls.closingConfirmations, [false, true, false]); + assert.deepEqual(providerClosingStates, [false, true, false]); + await serializedClosingApp.stop(); + + const rejectStaleEnable = deferred(); + const changedDuringFailure = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + onSetClosingConfirmation: async (enabled) => { + if (enabled) await rejectStaleEnable.promise; + }, + }); + const changedDuringFailureApp = bootSalvoApp(changedDuringFailure.dependencies); + await changedDuringFailureApp.startup.done; + await changedDuringFailure.root.click("start-agent"); + await changedDuringFailure.root.click("ready"); + await flushMicrotasks(); + assert.deepEqual(changedDuringFailure.calls.closingConfirmations, [false, true]); + + changedDuringFailureApp.getState().game.phase = "finished"; + changedDuringFailureApp.getState().game.winnerId = "p1"; + await changedDuringFailure.root.click("menu"); + assert.deepEqual(changedDuringFailure.calls.closingConfirmations, [false, true]); + + rejectStaleEnable.reject(new Error("private stale closing-confirmation detail")); + await flushMicrotasks(); + assert.deepEqual( + changedDuringFailure.calls.closingConfirmations, + [false, true, false], + "a newer desired state drains after the stale operation rejects", + ); + await changedDuringFailureApp.stop(); + + let enableAttempts = 0; + const retryClosing = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + onSetClosingConfirmation: async (enabled) => { + if (!enabled) return; + enableAttempts += 1; + if (enableAttempts === 1) { + throw new Error("private closing-confirmation provider detail"); + } + }, + }); + const retryClosingApp = bootSalvoApp(retryClosing.dependencies); + await retryClosingApp.startup.done; + await retryClosing.root.click("start-agent"); + await flushMicrotasks(); + assert.equal(enableAttempts, 1); + await flushMicrotasks(); + assert.equal(enableAttempts, 1, "rejection must not spin an immediate retry"); + + await retryClosing.root.click("theme-toggle"); + await flushMicrotasks(); + assert.equal(enableAttempts, 2, "a later render retries the unapplied state"); + assert.deepEqual(retryClosing.calls.closingConfirmations, [false, true, true]); + await retryClosingApp.stop(); +} + +async function runHapticRuntimeScenario() { + const hapticFailure = new Error("private haptic provider detail"); + const runtimeErrors = []; + const harness = createAppHarness({ + native: true, + onHaptic: async () => { + throw hapticFailure; + }, + }); + const { bootSalvoApp } = await import("../src/app.js"); + const originalConsoleError = console.error; + let app = null; + console.error = (...args) => runtimeErrors.push(args); + + try { + app = bootSalvoApp(harness.dependencies); + await app.startup.done; + assert.equal(app.getState().hapticsEnabled, true); + + await harness.root.click("start-agent"); + await harness.root.click("reset"); + assert.equal(app.getState().setupBoard.ships.length, 0); + const rendersBeforePlacement = harness.root.renderCount; + + await assert.doesNotReject(() => ( + harness.root.click("setup-cell", { row: "0", col: "0" }) + )); + await flushMicrotasks(); + + assert.deepEqual(harness.calls.haptics, ["placement"]); + assert.equal(app.getState().setupBoard.ships.length, 1); + assert.equal(app.getState().setupError, ""); + assert.equal(harness.root.renderCount, rendersBeforePlacement + 1); + assert.equal(runtimeErrors.length, 1); + assert.equal(runtimeErrors[0][0], "Salvo mobile runtime error"); + assert.equal(runtimeErrors[0][1], hapticFailure); + assert.doesNotMatch(harness.root.innerHTML, /private haptic provider detail/); + } finally { + console.error = originalConsoleError; + if (app) await app.stop(); + } +} + +async function runTelegramThemeBuildScenario() { + const { bootSalvoApp } = await import("../src/app.js"); + const inherited = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + platformTheme: "dark", + }); + const inheritedApp = bootSalvoApp(inherited.dependencies); + assert.equal(inheritedApp.getState().theme, "dark"); + await inheritedApp.startup.done; + await inherited.emitTheme("light"); + assert.equal(inheritedApp.getState().theme, "light"); + + const stored = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + platformTheme: "light", + preferences: resolvedDeferred("dark"), + }); + const storedApp = bootSalvoApp(stored.dependencies); + await storedApp.startup.done; + assert.equal(storedApp.getState().theme, "dark"); + await stored.emitTheme("light"); + assert.equal(storedApp.getState().theme, "dark", "stored theme overrides Telegram events"); + + const selected = createAppHarness({ + platformName: "telegram", + launchData: "signed-init-data", + platformTheme: "dark", + }); + const selectedApp = bootSalvoApp(selected.dependencies); + await selectedApp.startup.done; + await selected.root.click("theme-toggle"); + assert.equal(selectedApp.getState().theme, "light"); + await selected.emitTheme("dark"); + assert.equal(selectedApp.getState().theme, "light", "user-selected theme remains authoritative"); + + for (const [platformName, native] of [ + ["web", false], + ["android", true], + ["telegram", false], + ]) { + const runtime = createAppHarness({ + native, + platformName, + launchData: platformName === "telegram" ? "signed-init-data" : "", + buildId: "build_2026.07.17", + }); + const runtimeApp = bootSalvoApp(runtime.dependencies); + assert.match(runtime.root.innerHTML, /settings-build-id[^>]*>Build: build_2026\.07\.17' }); + const unsafeApp = bootSalvoApp(unsafe.dependencies); + assert.match(unsafe.root.innerHTML, /settings-build-id[^>]*>Build: dev Promise.resolve(), onSecureSet = () => Promise.resolve(), onOpenExternalUrl = () => Promise.resolve(), onCloseExternalUrl = () => Promise.resolve(), + onSetBackButtonVisible = () => Promise.resolve(), + onSetClosingConfirmation = () => Promise.resolve(), + onHaptic = () => Promise.resolve(), onSettingWrite = () => Promise.resolve(), + shareResult = { shared: false }, createRemoteClient = () => { throw new Error("Remote client was not expected"); }, @@ -709,10 +1955,19 @@ function createAppHarness({ snapshotReads: 0, secureClears: 0, secureSets: 0, + sharePayloads: [], + clipboardWrites: [], openedUrls: [], closedUrls: 0, + historyPushes: 0, historyReplacements: 0, settingWrites: [], + ready: 0, + backButtonVisibility: [], + closingConfirmations: [], + haptics: [], + audioPauses: 0, + audioResumes: 0, }; void preferences.promise.then(() => { calls.preferencesSettled = true; @@ -721,9 +1976,17 @@ function createAppHarness({ let lifecycleHandler = null; let deepLinkHandler = null; let networkHandler = null; + let backHandler = null; + let settingsHandler = null; + let themeHandler = null; + let viewportHandler = null; + let currentPlatformTheme = platformTheme; const platform = { isNative: () => native, getPlatform: () => platformName, + isAvailable: () => platformAvailable, + getLaunchData: () => launchData, + getStartParam: () => startParam, settings: { get(key) { if (key === "localBattle") { @@ -770,8 +2033,11 @@ function createAppHarness({ if (deepLinkHandler === handler) deepLinkHandler = null; }; }, - async onBack() { - return async () => {}; + async onBack(handler) { + backHandler = handler; + return async () => { + if (backHandler === handler) backHandler = null; + }; }, async onLifecycleChange(handler) { lifecycleHandler = handler; @@ -779,9 +2045,43 @@ function createAppHarness({ if (lifecycleHandler === handler) lifecycleHandler = null; }; }, - async haptic() {}, - async share() { - return { shared: false }; + async onSettings(handler) { + settingsHandler = handler; + return async () => { + if (settingsHandler === handler) settingsHandler = null; + }; + }, + getTheme: () => currentPlatformTheme, + async onThemeChange(handler) { + themeHandler = handler; + return async () => { + if (themeHandler === handler) themeHandler = null; + }; + }, + async onViewportChange(handler) { + viewportHandler = handler; + return async () => { + if (viewportHandler === handler) viewportHandler = null; + }; + }, + async ready() { + calls.ready += 1; + }, + async setBackButtonVisible(enabled) { + calls.backButtonVisibility.push(Boolean(enabled)); + await onSetBackButtonVisible(Boolean(enabled)); + }, + async setClosingConfirmation(enabled) { + calls.closingConfirmations.push(Boolean(enabled)); + await onSetClosingConfirmation(Boolean(enabled)); + }, + async haptic(event) { + calls.haptics.push(event); + await onHaptic(event); + }, + async share(payload) { + calls.sharePayloads.push(payload); + return shareResult; }, async openExternalUrl(url) { calls.openedUrls.push(url); @@ -792,22 +2092,60 @@ function createAppHarness({ await onCloseExternalUrl(); }, }; - const window = createWindowHarness({ initialUrl, workerUrl, calls }); + const window = createWindowHarness({ + initialUrl, + workerUrl, + telegramBotUsername, + buildId, + calls, + }); const navigator = { onLine: true, - clipboard: { async writeText() {} }, + clipboard: { + async writeText(value) { + calls.clipboardWrites.push(value); + }, + }, }; const audio = { async startMusic() {}, stopMusic() {}, async play() {}, - async pauseForLifecycle() {}, - async resumeForLifecycle() {}, + async pauseForLifecycle() { + calls.audioPauses += 1; + }, + async resumeForLifecycle() { + calls.audioResumes += 1; + }, }; return { calls, document, + activePlatformHandlers() { + return { + back: Boolean(backHandler), + deepLink: Boolean(deepLinkHandler), + lifecycle: Boolean(lifecycleHandler), + network: Boolean(networkHandler), + settings: Boolean(settingsHandler), + theme: Boolean(themeHandler), + viewport: Boolean(viewportHandler), + }; + }, + emitBack() { + assert.ok(backHandler, "Back handler is not registered"); + return backHandler(); + }, + emitSettings() { + assert.ok(settingsHandler, "Settings handler is not registered"); + return settingsHandler(); + }, + emitTheme(theme) { + currentPlatformTheme = theme; + assert.ok(themeHandler, "Theme handler is not registered"); + return themeHandler(theme); + }, emitLifecycle(event) { assert.ok(lifecycleHandler, "Lifecycle handler is not registered"); return lifecycleHandler(event); @@ -841,6 +2179,9 @@ function createAppHarness({ if (url.endsWith("/auth/telegram/mobile/redeem")) { return clientResult(redeemResponse); } + if (url.endsWith("/auth/telegram/miniapp")) { + return clientResult(miniAppResponse); + } return fetchResponse(url, init); }, }, @@ -914,6 +2255,7 @@ function createRootHarness(document) { }, }; let html = ""; + let renderCount = 0; let cancelControl = null; let confirmControl = null; let competingDialogControl = null; @@ -936,6 +2278,9 @@ function createRootHarness(document) { const root = { background, + get renderCount() { + return renderCount; + }, get competingDialogControl() { return competingDialogControl; }, @@ -943,6 +2288,7 @@ function createRootHarness(document) { return html; }, set innerHTML(value) { + renderCount += 1; html = value; telegramSlot = html.includes('id="telegram-login-slot"') ? { @@ -1014,6 +2360,8 @@ function createRootHarness(document) { function createWindowHarness({ initialUrl = "https://agent-axiom.github.io/agents-salvo/", workerUrl = "https://worker.example.test", + telegramBotUsername = "salvo_test_bot", + buildId, calls, } = {}) { const listeners = new Map(); @@ -1038,12 +2386,14 @@ function createWindowHarness({ return { SALVO_CONFIG: { workerUrl, - telegramBotUsername: "salvo_test_bot", + telegramBotUsername, + ...(buildId === undefined ? {} : { buildId }), }, location, history: { state: null, pushState(_state, _title, url) { + if (calls) calls.historyPushes += 1; updateLocation(url); }, replaceState(state, _title, url) { @@ -1081,6 +2431,7 @@ async function clientResult(result) { const pending = typeof result === "function" ? result() : result; const value = await pending; if (value instanceof Error) throw value; + if (value instanceof Response) return value; return new Response(JSON.stringify(value), { status: 200, headers: { "Content-Type": "application/json" }, @@ -1097,6 +2448,85 @@ function telegramUser(id, name) { }; } +function remoteClientHarness(overrides = {}) { + return { + close() {}, + async send() {}, + ...overrides, + }; +} + +function miniAppServiceFailure() { + return new Response(JSON.stringify({ error: "Telegram Mini App authentication failed" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); +} + +function archivedReplayFixture(id) { + return { + id, + version: 1, + presetId: "classic", + viewerPlayerId: "p1", + winnerId: "p1", + finishedAt: "2026-07-17T12:00:00.000Z", + players: { + p1: { name: "Viewer Captain", username: "viewer" }, + p2: { name: "Opponent Captain", username: "opponent" }, + }, + boards: { + p1: { + size: 4, + ships: [{ + id: "p1-patrol", + length: 1, + cells: [{ row: 1, col: 1 }], + hits: [{ row: 1, col: 1 }], + }], + markers: [], + shots: [{ row: 1, col: 1, result: "sunk", shipId: "p1-patrol" }], + }, + p2: { + size: 4, + ships: [{ + id: "p2-patrol", + length: 1, + cells: [{ row: 2, col: 3 }], + hits: [{ row: 2, col: 3 }], + }], + markers: [], + shots: [ + { row: 0, col: 0, result: "miss" }, + { row: 2, col: 3, result: "sunk", shipId: "p2-patrol" }, + ], + }, + }, + log: [ + { + playerId: "p1", + targetPlayerId: "p2", + coordinate: { row: 0, col: 0 }, + result: "miss", + }, + { + playerId: "p2", + targetPlayerId: "p1", + coordinate: { row: 1, col: 1 }, + result: "sunk", + shipId: "p1-patrol", + }, + { + playerId: "p1", + targetPlayerId: "p2", + coordinate: { row: 2, col: 3 }, + result: "sunk", + shipId: "p2-patrol", + }, + ], + }; +} + function deferred() { let resolve; let reject; diff --git a/tests/app-behavior.test.mjs b/tests/app-behavior.test.mjs index f2c5e38..49ab3b5 100644 --- a/tests/app-behavior.test.mjs +++ b/tests/app-behavior.test.mjs @@ -43,6 +43,46 @@ test("actual app cleans and redeems web Telegram bootstrap callbacks", async () await runScenarioInChild("auth-bootstrap"); }); +test("actual app authenticates Telegram Mini App launch data automatically", async () => { + await runScenarioInChild("telegram-bootstrap"); +}); + +test("actual app routes Telegram room and replay launches after authentication", async () => { + await runScenarioInChild("telegram-launch-routing"); +}); + +test("actual app retains Telegram room and replay launches across auth retry", async () => { + await runScenarioInChild("telegram-launch-retry"); +}); + +test("actual app makes valid Telegram launch params authoritative over URL replays", async () => { + await runScenarioInChild("telegram-launch-authority"); +}); + +test("actual app shares Telegram launches without changing web canonical links", async () => { + await runScenarioInChild("telegram-launch-sharing"); +}); + +test("actual app preserves online connection status while Telegram sharing settles", async () => { + await runScenarioInChild("telegram-share-status-race"); +}); + +test("actual app keeps Telegram Mini App auth failures recoverable and race-safe", async () => { + await runScenarioInChild("telegram-auth-recovery"); +}); + +test("actual app connects Telegram runtime controls through mobile lifecycle cleanup", async () => { + await runScenarioInChild("telegram-runtime"); +}); + +test("actual app contains rejected haptics without interrupting gameplay renders", async () => { + await runScenarioInChild("haptic-runtime"); +}); + +test("actual app honors Telegram theme precedence and renders safe build metadata", async () => { + await runScenarioInChild("telegram-theme-build"); +}); + test("actual app retries Telegram capability and rejects failed secure persistence", async () => { await runScenarioInChild("auth-recovery"); }); diff --git a/tests/audio.test.mjs b/tests/audio.test.mjs index 633a132..4a525c0 100644 --- a/tests/audio.test.mjs +++ b/tests/audio.test.mjs @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { access, readdir } from "node:fs/promises"; import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import * as audioCore from "../src/core/audio.js"; import { createAudioController } from "../src/audio.js"; @@ -26,16 +27,22 @@ test("soundPresets include all gameplay and interface events", () => { }); test("menuMusicTracks include both mp3 menu loops", () => { - assert.deepEqual(audioCore.menuMusicTracks, [ - "./assets/audio/menu-loop.mp3", - "./assets/audio/menu-loop-v2.mp3", - ]); + for (const source of audioCore.menuMusicTracks) { + assert.match(source, /^file:/); + } + assert.deepEqual( + audioCore.menuMusicTracks.map((source) => fileURLToPath(source)), + [ + resolve("src/assets/audio/menu-loop.mp3"), + resolve("src/assets/audio/menu-loop-v2.mp3"), + ], + ); }); test("configured menu mp3 audio assets exist in source tree", async () => { for (const source of audioCore.menuMusicTracks) { - assert.match(source, /^\.\/assets\/audio\/.+\.mp3$/); - await access(resolve("src", source.slice(2))); + assert.match(source, /^file:.+\/assets\/audio\/.+\.mp3$/); + await access(fileURLToPath(source)); } }); diff --git a/tests/auth-ui.test.mjs b/tests/auth-ui.test.mjs index c141dbe..b81d06b 100644 --- a/tests/auth-ui.test.mjs +++ b/tests/auth-ui.test.mjs @@ -2,11 +2,69 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { t } from "../src/i18n.js"; + const app = readFileSync("src/app.js", "utf8"); const index = readFileSync("src/index.html", "utf8"); const remote = readFileSync("src/remote.js", "utf8"); const styles = readFileSync("src/styles.css", "utf8"); +test("Telegram Mini App copy covers auth, room failure, and account status in every locale", () => { + const expected = { + en: { + "auth.miniAppOpenInTelegram": "Open Salvo in Telegram to sign in.", + "auth.miniAppReopen": "This Telegram Mini App session expired. Reopen Salvo to sign in again.", + "auth.retry": "Retry", + "online.roomUnavailable": "This room is full, closed, or unavailable. Return to the online lobby and try another room.", + "auth.miniAppAccountStatus": "Telegram Mini App account confirmed. Your existing profile and online progress are available.", + }, + ru: { + "auth.miniAppOpenInTelegram": "Откройте Залп в Telegram, чтобы войти.", + "auth.miniAppReopen": "Сеанс Telegram Mini App истёк. Откройте Залп снова, чтобы войти.", + "auth.retry": "Повторить", + "online.roomUnavailable": "Комната заполнена, закрыта или недоступна. Вернитесь в онлайн-лобби и выберите другую комнату.", + "auth.miniAppAccountStatus": "Аккаунт Telegram Mini App подтверждён. Ваш существующий профиль и онлайн-прогресс доступны.", + }, + "zh-CN": { + "auth.miniAppOpenInTelegram": "请在 Telegram 中打开 Salvo 以登录。", + "auth.miniAppReopen": "Telegram Mini App 会话已过期。请重新打开 Salvo 以登录。", + "auth.retry": "重试", + "online.roomUnavailable": "此房间已满、已关闭或不可用。请返回在线大厅并尝试其他房间。", + "auth.miniAppAccountStatus": "Telegram Mini App 账号已确认。您可以继续使用现有档案和在线进度。", + }, + }; + + for (const [language, copy] of Object.entries(expected)) { + for (const [key, value] of Object.entries(copy)) { + assert.equal(t(language, key), value, `${language} must define ${key}`); + } + } +}); + +test("Telegram runtime consumes content safe areas and the stable viewport", () => { + for (const side of ["top", "right", "bottom", "left"]) { + assert.match( + styles, + new RegExp(`--salvo-safe-${side}:\\s*var\\(--tg-content-safe-area-inset-${side},\\s*env\\(safe-area-inset-${side},\\s*0px\\)\\)`), + ); + } + assert.match(styles, /html\[data-runtime="telegram"\]\s*\{[\s\S]*?--salvo-safe-top:/); + assert.match(styles, /html\[data-runtime="telegram"\] \.shell\s*\{[\s\S]*?min-height:\s*var\(--tg-viewport-stable-height,[^;]+\);/); + assert.match(styles, /html\[data-runtime="telegram"\] \.shell\s*\{[\s\S]*?var\(--salvo-safe-top\)[\s\S]*?var\(--salvo-safe-right\)[\s\S]*?var\(--salvo-safe-bottom\)[\s\S]*?var\(--salvo-safe-left\)/); + assert.match(styles, /html\[data-runtime="telegram"\] \.modal-backdrop\s*\{[\s\S]*?var\(--salvo-safe-top\)[\s\S]*?var\(--salvo-safe-right\)[\s\S]*?var\(--salvo-safe-bottom\)[\s\S]*?var\(--salvo-safe-left\)/); +}); + +test("Telegram phone boards fit without changing web and native replay overflow", () => { + const phoneStyles = styles.slice(styles.indexOf("@media (max-width: 720px)")); + assert.doesNotMatch(phoneStyles, /(?:^|\n) body\s*\{/); + assert.match(phoneStyles, /\.board-scroll\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*overflow-x:\s*clip/); + assert.match(phoneStyles, /\.column-headers,[\s\S]*?\.board-grid\s*\{[^}]*minmax\(0,\s*1fr\)/); + assert.match(phoneStyles, /html\[data-runtime="telegram"\] \.replay-board-view\s*\{[^}]*overflow-x:\s*clip/); + assert.match(phoneStyles, /html\[data-runtime="telegram"\] \.replay-board-view \.board-panel\s*\{[^}]*width:\s*100%/); + assert.doesNotMatch(phoneStyles, /(?:^|\n) \.replay-board-view\s*\{/); + assert.doesNotMatch(phoneStyles, /html\[data-runtime="telegram"\] \.replay-board-view \.column-headers,[^}]*font-size/); +}); + test("frontend config exposes the public Telegram bot username only", () => { assert.match(index, /telegramBotUsername:\s*"agents_salvo_bot"/); assert.doesNotMatch(index, /TELEGRAM_BOT_TOKEN|SESSION_SECRET/); @@ -31,6 +89,30 @@ test("frontend selects legacy or OIDC Telegram login from worker capability", () assert.doesNotMatch(app, /auth\.mobileSecureLoginPending/); }); +test("Telegram Mini App auth is isolated from legacy Telegram login startup", () => { + assert.match(app, /import \{ createTelegramMiniAppAuthClient \} from "\.\/telegram-mini-app-auth\.js"/); + assert.match(app, /platform\.getPlatform\(\) === "telegram"/); + assert.match(app, /createTelegramMiniAppAuthClient\(\{/); + assert.match(app, /authenticateTelegramMiniApp/); + assert.match(app, /platform\.getLaunchData\(\)/); + assert.match(app, /miniapp-unavailable/); + assert.match(app, /telegramMiniAppClient\.authenticate\(launchData/); + assert.match(app, /establishAuthSession\(/); + assert.match(app, /telegramMainMiniAppUrl/); + assert.match(app, /miniapp-expired/); + assert.match(app, /auth\.miniAppOpenInTelegram/); + assert.match(app, /auth\.miniAppReopen/); + assert.match(app, /action: "auth-miniapp-open"/); + assert.match(app, /action: "auth-miniapp-reopen"/); +}); + +test("runtime settings metadata validates and escapes the shared build identifier", () => { + assert.match(app, /\^\[A-Za-z0-9\._-\]\{1,64\}\$/); + assert.match(app, /window\.SALVO_CONFIG\?\.buildId/); + assert.match(app, /settings-build-id/); + assert.match(app, /escapeHtml\(buildId\)/); +}); + test("Telegram login requires an explicit, readable privacy consent control", () => { assert.match(app, /data-action="auth-consent"/); assert.match(app, /authConsentSettingKey/); @@ -74,7 +156,9 @@ test("online room actions require a registered Telegram player in the UI", () => assert.match(app, /online\.authRequired/); assert.match(app, /data-action="online-create"[^>]*\$\{onlineDisabled\}/); assert.match(app, /data-action="online-join"[^>]*\$\{onlineDisabled\}/); - assert.match(app, /if \(!isOnlineAuthReady\(\)\) \{\s*state\.online\.error = translate\("online\.authRequired"\);/s); + assert.match(app, /if \(!isOnlineAuthReady\(\)\) \{\s*setOnlineError\(translate\("online\.authRequired"\)\);/s); + assert.match(app, /function renderOnlineError\(\)/); + assert.match(app, /role="alert" aria-live="assertive"/); }); test("private replay archive state uses authenticated participant endpoints", () => { diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs index 9839a13..8ef778e 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -59,6 +59,26 @@ test("verifyTelegramLoginPayload rejects tampered Telegram payloads", async () = ); }); +test("verifyTelegramLoginPayload rejects non-hex and truncated signatures", async () => { + const botToken = "123456:secret-token"; + const basePayload = { + id: "42", + first_name: "Ivan", + auth_date: "1700000000", + }; + + for (const hash of ["not-hex", "aa"]) { + await assert.rejects( + verifyTelegramLoginPayload( + { ...basePayload, hash }, + botToken, + { now: 1700000100, maxAgeSeconds: 86400 }, + ), + /Invalid Telegram signature/, + ); + } +}); + test("verifyTelegramLoginPayload rejects stale Telegram payloads", async () => { const botToken = "123456:secret-token"; const payload = { diff --git a/tests/game.test.mjs b/tests/game.test.mjs index a9211e2..7c0842b 100644 --- a/tests/game.test.mjs +++ b/tests/game.test.mjs @@ -93,6 +93,20 @@ test("placeShip rejects ships touching by side or corner", () => { ); }); +test("placeShip rejects overlap and contact with special cells", () => { + const marker = { id: "mine-1", type: "mine" }; + const board = placeMarker(createBoard(), marker, { row: 4, col: 4 }); + + assert.throws( + () => placeShip(board, { id: "overlap-mine", length: 1 }, { row: 4, col: 4 }, "horizontal"), + /overlap a special cell/i, + ); + assert.throws( + () => placeShip(board, { id: "touch-mine", length: 1 }, { row: 3, col: 3 }, "horizontal"), + /touch a special cell/i, + ); +}); + test("removeShip removes one placed ship and keeps the rest of the board", () => { let board = createBoard(); board = placeShip(board, { id: "battleship", length: 4 }, { row: 0, col: 0 }, "horizontal"); @@ -153,6 +167,24 @@ test("randomlyPlaceSetup supports quick and extended presets", () => { assert.equal(hasCompleteSetup(extended, gamePresets.perelman), true); }); +test("random placement rejects fleets and marker sets that cannot fit", () => { + assert.throws( + () => randomlyPlaceFleet([{ id: "oversized", length: 2 }], 1, () => 0), + /No valid placement found for oversized/, + ); + assert.throws( + () => randomlyPlaceSetup({ + size: 1, + fleet: [], + markers: [ + { id: "mine-1", type: "mine" }, + { id: "mine-2", type: "mine" }, + ], + }, () => 0), + /No valid placement found for mine-2/, + ); +}); + test("placeMarker places and removes mines and sweepers without touching ships", () => { let board = createBoard(8); board = placeShip(board, { id: "patrol", length: 2 }, { row: 0, col: 0 }, "horizontal"); diff --git a/tests/i18n.test.mjs b/tests/i18n.test.mjs index 126ddb0..72c2384 100644 --- a/tests/i18n.test.mjs +++ b/tests/i18n.test.mjs @@ -45,6 +45,10 @@ test("mobile platform notices and controls are localized in every language", () "auth.cancelled": "Telegram sign-in was cancelled. Try again when ready.", "auth.invalidTicket": "This sign-in link expired or is invalid. Please try again.", "auth.unavailable": "Telegram login is unavailable right now.", + "auth.miniAppOpenInTelegram": "Open Salvo in Telegram to sign in.", + "auth.miniAppOpenCommand": "Open in Telegram", + "auth.miniAppReopen": "This Telegram Mini App session expired. Reopen Salvo to sign in again.", + "auth.miniAppReopenCommand": "Reopen in Telegram", "auth.retry": "Retry", "auth.valueNotice": "Save your profile and online progress. Local play remains available.", "auth.privacyNotice": "Read how account data is handled in the", @@ -70,6 +74,10 @@ test("mobile platform notices and controls are localized in every language", () "auth.cancelled": "Вход через Telegram отменён. Повторите попытку, когда будете готовы.", "auth.invalidTicket": "Ссылка для входа недействительна или устарела. Попробуйте ещё раз.", "auth.unavailable": "Вход через Telegram сейчас недоступен.", + "auth.miniAppOpenInTelegram": "Откройте Залп в Telegram, чтобы войти.", + "auth.miniAppOpenCommand": "Открыть в Telegram", + "auth.miniAppReopen": "Сеанс Telegram Mini App истёк. Откройте Залп снова, чтобы войти.", + "auth.miniAppReopenCommand": "Открыть снова в Telegram", "auth.retry": "Повторить", "auth.valueNotice": "Сохраняйте профиль и прогресс онлайн. Локальная игра остаётся доступной.", "auth.privacyNotice": "О работе с данными аккаунта читайте в", @@ -95,6 +103,10 @@ test("mobile platform notices and controls are localized in every language", () "auth.cancelled": "已取消 Telegram 登录,准备好后可重试。", "auth.invalidTicket": "此登录链接已过期或无效,请重试。", "auth.unavailable": "Telegram 登录暂时不可用。", + "auth.miniAppOpenInTelegram": "请在 Telegram 中打开 Salvo 以登录。", + "auth.miniAppOpenCommand": "在 Telegram 中打开", + "auth.miniAppReopen": "Telegram Mini App 会话已过期。请重新打开 Salvo 以登录。", + "auth.miniAppReopenCommand": "在 Telegram 中重新打开", "auth.retry": "重试", "auth.valueNotice": "保存个人档案和在线进度;本地游戏仍可使用。", "auth.privacyNotice": "账号数据处理方式请参阅", @@ -168,6 +180,10 @@ test("i18n translates result modal, theme, and history labels in every language" "auth.cancelled", "auth.invalidTicket", "auth.unavailable", + "auth.miniAppOpenInTelegram", + "auth.miniAppOpenCommand", + "auth.miniAppReopen", + "auth.miniAppReopenCommand", "auth.retry", "auth.valueNotice", "auth.privacyNotice", diff --git a/tests/mobile-app-support.test.mjs b/tests/mobile-app-support.test.mjs index a8e33f9..c6087c1 100644 --- a/tests/mobile-app-support.test.mjs +++ b/tests/mobile-app-support.test.mjs @@ -76,6 +76,40 @@ test("startup begins runtime while preferences remain pending and gates network await startup.done; }); +test("startup processes a launch exactly once after authentication settles", async () => { + const authentication = deferred(); + const calls = []; + const startup = startMobileAppServices({ + async startRuntime() { + calls.push("runtime"); + }, + async hydratePreferences() {}, + async hydrateSecureSession() {}, + async refreshAuth() { + calls.push("auth-start"); + await authentication.promise; + calls.push("auth-settled"); + }, + async processLaunch() { + calls.push("launch"); + }, + async refreshLeaderboard() {}, + onError(error) { + assert.fail(error); + }, + }); + + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ["runtime", "auth-start"]); + + authentication.resolve(); + await Promise.all([startup.launchReady, startup.done]); + assert.deepEqual(calls, ["runtime", "auth-start", "auth-settled", "launch"]); + + await startup.launchReady; + assert.equal(calls.filter((call) => call === "launch").length, 1); +}); + test("network requests stay fail closed until a connected platform sample", () => { const initial = createUnknownNetworkState(); assert.deepEqual(initial, { diff --git a/tests/mobile-build.test.mjs b/tests/mobile-build.test.mjs index 485cc47..325112e 100644 --- a/tests/mobile-build.test.mjs +++ b/tests/mobile-build.test.mjs @@ -1,6 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { execFileSync, spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -183,6 +191,57 @@ function listFiles(root) { }); } +function withTemporaryBuildTree(callback) { + const buildOutput = mkdtempSync(join(tmpdir(), "salvo-mobile-build-")); + try { + return callback(buildOutput); + } finally { + rmSync(buildOutput, { recursive: true, force: true }); + } +} + +function runTelegramArtifactVerification(workflowPath, jobName, sdkCount) { + const workflow = parseWorkflowSource( + readFileSync(workflowPath, "utf8"), + workflowPath, + ); + const command = namedWorkflowStep( + workflow, + jobName, + "Verify Telegram Mini App artifacts", + ).step.run; + + return withTemporaryBuildTree((fixtureRoot) => { + const dist = join(fixtureRoot, "dist"); + const telegramDist = join(dist, "telegram"); + const app = "app.0123456789.js"; + const styles = "styles.0123456789.css"; + const buildId = "fixture-build-id"; + const sdkTag = + ''; + + mkdirSync(telegramDist, { recursive: true }); + writeFileSync(join(dist, app), "", "utf8"); + writeFileSync(join(dist, styles), "", "utf8"); + writeFileSync( + join(dist, "index.html"), + `\n\n`, + "utf8", + ); + writeFileSync( + join(telegramDist, "index.html"), + `${sdkTag.repeat(sdkCount)}\n\n`, + "utf8", + ); + + return spawnSync("bash", ["-c", command], { + cwd: fixtureRoot, + encoding: "utf8", + env: { ...process.env, SALVO_BUILD_ID: buildId }, + }); + }); +} + function readWorkflow(path) { assert.equal(existsSync(path), true, `${path} is missing`); const source = readFileSync(path, "utf8"); @@ -263,6 +322,75 @@ function scalarRunCommands(workflow) { ); } +function namedWorkflowStep(workflow, jobName, stepName) { + const steps = workflow.jobs[jobName]?.steps; + assert.equal(Array.isArray(steps), true, `${jobName} job steps are missing`); + const matches = steps + .map((step, index) => ({ index, step })) + .filter(({ step }) => step.name === stepName); + assert.equal( + matches.length, + 1, + `${jobName} must contain exactly one ${stepName} step`, + ); + return matches[0]; +} + +function assertCommitBuildId(step, label) { + assert.deepEqual(step.env, { + SALVO_BUILD_ID: "${{ github.sha }}", + }, `${label} must use the triggering commit as its build ID`); +} + +function assertTelegramArtifactVerification(workflow, jobName) { + const build = namedWorkflowStep(workflow, jobName, "Build"); + assert.equal(build.step.run, "npm run build"); + assertCommitBuildId(build.step, `${jobName} Build`); + + const verification = namedWorkflowStep( + workflow, + jobName, + "Verify Telegram Mini App artifacts", + ); + assert.equal( + verification.index, + build.index + 1, + `${jobName} must verify the artifacts immediately after building`, + ); + assert.equal(verification.step.shell, "bash"); + assertCommitBuildId(verification.step, `${jobName} artifact verification`); + assert.equal( + verification.step.run.trim(), + `set -euo pipefail +test -f dist/telegram/index.html +sdk_occurrences="$( + awk ' + { + remainder = $0 + token = "telegram-web-app.js" + while ((position = index(remainder, token)) > 0) { + count += 1 + remainder = substr(remainder, position + length(token)) + } + } + END { print count + 0 } + ' dist/telegram/index.html +)" +test "$sdk_occurrences" -eq 1 +! grep -Fq 'telegram-web-app.js' dist/index.html +web_app="$(grep -oE 'app\\.[a-f0-9]{10}\\.js' dist/index.html)" +telegram_app="$(grep -oE 'app\\.[a-f0-9]{10}\\.js' dist/telegram/index.html)" +test "$web_app" = "$telegram_app" +test -f "dist/$web_app" +web_styles="$(grep -oE 'styles\\.[a-f0-9]{10}\\.css' dist/index.html)" +telegram_styles="$(grep -oE 'styles\\.[a-f0-9]{10}\\.css' dist/telegram/index.html)" +test "$web_styles" = "$telegram_styles" +test -f "dist/$web_styles" +grep -Fq "buildId: \\"$SALVO_BUILD_ID\\"" dist/index.html +grep -Fq "buildId: \\"$SALVO_BUILD_ID\\"" dist/telegram/index.html`, + ); +} + test("mobile toolchain is pinned and uses bundled local web assets", () => { assert.equal(readFileSync(".nvmrc", "utf8").trim(), "24.14.1"); assert.equal(packageJson.engines.node, ">=24.14.1 <25"); @@ -352,18 +480,59 @@ test("Capacitor CLI loads the local TypeScript config", async () => { assert.equal(config.app.extConfig.server?.url, undefined); }); -test("mobile build emits bundled local web artifacts", () => { - const buildOutput = mkdtempSync(join(tmpdir(), "salvo-mobile-build-")); +function assertBundledLocalWebArtifacts(buildOutput) { execFileSync(process.execPath, ["scripts/build.mjs"], { env: { ...process.env, SALVO_BUILD_DIR: buildOutput }, }); assert.equal(existsSync(join(buildOutput, "index.html")), true); + assert.equal(existsSync(join(buildOutput, "telegram/index.html")), true); assert.equal(existsSync(join(buildOutput, "assets")), true); + const rootShell = readFileSync(join(buildOutput, "index.html"), "utf8"); + const telegramShell = readFileSync( + join(buildOutput, "telegram/index.html"), + "utf8", + ); + const appReferences = [ + ...rootShell.matchAll(/src="\.\/(app\.[a-f0-9]{10}\.js)"/g), + ]; + const styleReferences = [ + ...rootShell.matchAll(/href="\.\/(styles\.[a-f0-9]{10}\.css)"/g), + ]; + assert.equal(appReferences.length, 1); + assert.equal(styleReferences.length, 1); + assert.doesNotMatch(rootShell, /telegram-web-app\.js/); + assert.equal( + (telegramShell.match(/telegram-web-app\.js/g) ?? []).length, + 1, + ); + assert.equal( + telegramShell.match(/src="\.\.\/(app\.[a-f0-9]{10}\.js)"/)?.[1], + appReferences[0][1], + ); assert.equal( - readFileSync(join(buildOutput, "index.html"), "utf8"), - readFileSync("src/index.html", "utf8"), + telegramShell.match(/href="\.\.\/(styles\.[a-f0-9]{10}\.css)"/)?.[1], + styleReferences[0][1], ); + assert.match(rootShell, /buildId: "dev"/); + assert.match(telegramShell, /buildId: "dev"/); + + const localAssetReferences = [ + ...rootShell.matchAll(/(?:href|src)="([^"]+)"/g), + ].map((match) => match[1]); + assert.equal(localAssetReferences.length > 0, true); + for (const reference of localAssetReferences) { + assert.match( + reference, + /^\.\//, + `${reference} must be a local native asset`, + ); + assert.equal( + existsSync(join(buildOutput, reference.slice(2))), + true, + `${reference} is missing from the native build`, + ); + } assert.deepEqual( readFileSync( join(buildOutput, "assets/images/backgrounds/paper-texture-512.png"), @@ -372,7 +541,10 @@ test("mobile build emits bundled local web artifacts", () => { "src/assets/images/backgrounds/paper-texture-512.png", ), ); - const bundledApp = readFileSync(join(buildOutput, "app.js"), "utf8"); + const bundledApp = readFileSync( + join(buildOutput, appReferences[0][1]), + "utf8", + ); assert.doesNotMatch( bundledApp, /(?:from\s*|import\s*(?:\(\s*)?)["']@capacitor\//, @@ -381,6 +553,26 @@ test("mobile build emits bundled local web artifacts", () => { bundledApp, /(?:from\s*|import\s*(?:\(\s*)?)["']\.\.?\//, ); +} + +test("mobile build emits bundled local web artifacts", () => { + withTemporaryBuildTree(assertBundledLocalWebArtifacts); +}); + +test("temporary mobile build trees are removed after assertion failures", () => { + let buildOutput; + + assert.throws( + () => + withTemporaryBuildTree((path) => { + buildOutput = path; + writeFileSync(join(path, "partial-build.txt"), "partial", "utf8"); + assert.fail("simulated build assertion failure"); + }), + /simulated build assertion failure/, + ); + assert.notEqual(buildOutput, undefined); + assert.equal(existsSync(buildOutput), false); }); test("build output override rejects non-temporary directories before deleting files", () => { @@ -810,10 +1002,42 @@ jobs: ); }); +test("Telegram artifact verification enforces one exact SDK token", () => { + const targets = [ + [".github/workflows/pages.yml", "build"], + [".github/workflows/mobile.yml", "web"], + ]; + const validResults = targets.map(([path, job]) => + runTelegramArtifactVerification(path, job, 1), + ); + const duplicateResults = targets.map(([path, job]) => + runTelegramArtifactVerification(path, job, 2), + ); + + for (const result of [...validResults, ...duplicateResults]) { + assert.equal(result.error, undefined); + assert.equal(result.signal, null); + } + assert.deepEqual( + validResults.map(({ status }) => status), + [0, 0], + "each workflow must accept exactly one SDK token", + ); + assert.deepEqual( + duplicateResults.map(({ status }) => status), + [1, 1], + "each workflow must reject two SDK tokens even when they share one line", + ); +}); + test("Pages CI uses the pinned Node toolchain and preserves deployment", () => { const workflow = readWorkflow(".github/workflows/pages.yml"); + const parsedWorkflow = parseWorkflowSource( + workflow, + ".github/workflows/pages.yml", + ); assertWorkflowStructure( - parseWorkflowSource(workflow, ".github/workflows/pages.yml"), + parsedWorkflow, { triggers: ["push", "workflow_dispatch"], jobs: ["build", "deploy"] }, ); const build = workflowJob(workflow, "build"); @@ -832,6 +1056,7 @@ test("Pages CI uses the pinned Node toolchain and preserves deployment", () => { "npm run coverage", "npm run build", ]); + assertTelegramArtifactVerification(parsedWorkflow, "build"); assert.match(build, /uses: actions\/configure-pages@v6/); assert.match( build, @@ -882,6 +1107,10 @@ test("mobile CI covers branch builds without signing credentials", () => { test("mobile CI validates the web build and coverage gate", () => { const workflow = readWorkflow(".github/workflows/mobile.yml"); + const parsedWorkflow = parseWorkflowSource( + workflow, + ".github/workflows/mobile.yml", + ); const web = workflowJob(workflow, "web"); assert.match(web, /^ web:\n runs-on: ubuntu-latest$/m); @@ -897,12 +1126,18 @@ test("mobile CI validates the web build and coverage gate", () => { "npm run coverage", "npm run build", ]); + assertTelegramArtifactVerification(parsedWorkflow, "web"); }); test("mobile CI tests, lints, and packages the Android debug app", () => { const workflow = readWorkflow(".github/workflows/mobile.yml"); + const parsedWorkflow = parseWorkflowSource( + workflow, + ".github/workflows/mobile.yml", + ); const android = workflowJob(workflow, "android"); + assert.equal(parsedWorkflow.jobs.android.needs, "web"); assert.match(android, /^ android:\n runs-on: ubuntu-latest$/m); assert.deepEqual(actionVersions(android, "actions/checkout"), ["v7"]); assert.deepEqual(actionVersions(android, "actions/setup-node"), ["v7"]); @@ -920,6 +1155,10 @@ test("mobile CI tests, lints, and packages the Android debug app", () => { "npm run mobile:sync", "android/gradlew -p android test lint assembleDebug", ]); + assertCommitBuildId( + namedWorkflowStep(parsedWorkflow, "android", "Sync native projects").step, + "Android native sync", + ); assert.deepEqual( actionVersions(android, "ReactiveCircus/android-emulator-runner"), ["a421e43855164a8197daf9d8d40fe71c6996bb0d"], @@ -936,8 +1175,13 @@ test("mobile CI tests, lints, and packages the Android debug app", () => { test("mobile CI builds an unsigned iOS Simulator app and retains failures", () => { const workflow = readWorkflow(".github/workflows/mobile.yml"); + const parsedWorkflow = parseWorkflowSource( + workflow, + ".github/workflows/mobile.yml", + ); const ios = workflowJob(workflow, "ios"); + assert.equal(parsedWorkflow.jobs.ios.needs, "web"); assert.match(ios, /^ ios:\n runs-on: macos-26$/m); assert.deepEqual(actionVersions(ios, "actions/checkout"), ["v7"]); assert.deepEqual(actionVersions(ios, "actions/setup-node"), ["v7"]); @@ -950,6 +1194,10 @@ test("mobile CI builds an unsigned iOS Simulator app and retains failures", () = "npm ci", "npm run mobile:sync", ]); + assertCommitBuildId( + namedWorkflowStep(parsedWorkflow, "ios", "Sync native projects").step, + "iOS native sync", + ); assert.match( ios, /if: \$\{\{ always\(\) && steps\.ios_build\.outcome == 'failure' \}\}\n\s+uses: actions\/upload-artifact@v7\n\s+with:\n\s+name: ios-xcodebuild-log\n\s+path: xcodebuild\.log\n\s+if-no-files-found: error/, @@ -959,3 +1207,49 @@ test("mobile CI builds an unsigned iOS Simulator app and retains failures", () = /id: ios_build\n\s+shell: bash\n\s+run: \|\n\s+set -o pipefail\n\s+xcodebuild -project ios\/App\/App\.xcodeproj -scheme App -sdk iphonesimulator -configuration Debug CODE_SIGNING_ALLOWED=NO build 2>&1 \| tee xcodebuild\.log/, ); }); + +test("localized READMEs document Telegram Mini App delivery", () => { + const publicMiniAppUrl = + "https://agent-axiom.github.io/agents-salvo/telegram/"; + const expectations = [ + { + path: "README.md", + publicUrl: /^Telegram Mini App: https:\/\/agent-axiom\.github\.io\/agents-salvo\/telegram\/$/m, + automaticAuth: /automatically sends Telegram's signed `initData`/, + sharedBuild: /one source tree and one `npm run build`/, + delivery: /Pages and the Mini App update immediately[\s\S]*selected commit/, + }, + { + path: "README.ru.md", + publicUrl: /^Публичный Telegram Mini App: https:\/\/agent-axiom\.github\.io\/agents-salvo\/telegram\/$/m, + automaticAuth: /автоматически отправляет подписанный Telegram `initData`/, + sharedBuild: /единое дерево исходного кода и одну команду `npm run build`/, + sdkLoading: "Telegram SDK загружается только в Telegram shell.", + delivery: /Pages и Mini App обновляются сразу[\s\S]*выбранного коммита/, + }, + { + path: "README.zh-CN.md", + publicUrl: /^Telegram Mini App:https:\/\/agent-axiom\.github\.io\/agents-salvo\/telegram\/$/m, + automaticAuth: /自动将 Telegram 签名的 `initData`/, + sharedBuild: /共享同一份源代码,并由一次 `npm run build`/, + delivery: /Pages 和 Mini App 会立即更新[\s\S]*所选提交/, + }, + ]; + + for (const expectation of expectations) { + const readme = readFileSync(expectation.path, "utf8"); + assert.equal(readme.includes(publicMiniAppUrl), true, expectation.path); + assert.match(readme, expectation.publicUrl, expectation.path); + assert.match(readme, /@BotFather[\s\S]*Main Mini App/, expectation.path); + assert.match(readme, expectation.automaticAuth, expectation.path); + assert.match(readme, expectation.sharedBuild, expectation.path); + if (expectation.sdkLoading) { + assert.equal( + readme.includes(expectation.sdkLoading), + true, + expectation.path, + ); + } + assert.match(readme, expectation.delivery, expectation.path); + } +}); diff --git a/tests/platform.test.mjs b/tests/platform.test.mjs index 8ae9635..a7acdb0 100644 --- a/tests/platform.test.mjs +++ b/tests/platform.test.mjs @@ -785,16 +785,34 @@ test("native secure sessions persist only through protected device storage", asy test("platform selection is explicit and safe during Node import", () => { const web = selectPlatform(false); const native = selectPlatform(true); + const telegram = selectPlatform(false, { + runtime: "telegram", + telegramWebApp: { + initData: "signed-init-data", + initDataUnsafe: { start_param: "room_ABCD" }, + }, + }); + const nativeWins = selectPlatform(true, { + runtime: "telegram", + telegramWebApp: { initData: "signed-init-data" }, + }); assert.equal(web.isNative(), false); assert.equal(web.getPlatform(), "web"); assert.equal(native.isNative(), true); + assert.equal(telegram.getPlatform(), "telegram"); + assert.equal(telegram.getLaunchData(), "signed-init-data"); + assert.equal(nativeWins.isNative(), true); + assert.notEqual(nativeWins.getPlatform(), "telegram"); assert.equal(platform.isNative(), false); - for (const adapter of [web, native]) { + for (const adapter of [web, native, telegram]) { for (const method of [ "isNative", "getPlatform", + "isAvailable", + "getLaunchData", + "getStartParam", "getNetworkStatus", "onNetworkChange", "share", @@ -803,6 +821,13 @@ test("platform selection is explicit and safe during Node import", () => { "onDeepLink", "onBack", "onLifecycleChange", + "onSettings", + "ready", + "setBackButtonVisible", + "setClosingConfirmation", + "getTheme", + "onThemeChange", + "onViewportChange", "hideSplash", "configureSystemBars", ]) { @@ -815,3 +840,27 @@ test("platform selection is explicit and safe during Node import", () => { assert.equal(typeof adapter.secureSession.clear, "function"); } }); + +test("web and native expose safe no-op Telegram capabilities", async () => { + const adapters = [ + createWebPlatform({ window: undefined, navigator: undefined, storage: undefined }), + createNativePlatform(nativePlugins()), + ]; + + for (const adapter of adapters) { + assert.equal(adapter.isAvailable(), true); + assert.equal(adapter.getLaunchData(), ""); + assert.equal(adapter.getStartParam(), ""); + assert.equal(adapter.getTheme(), null); + const removeSettings = await adapter.onSettings(() => {}); + const removeTheme = await adapter.onThemeChange(() => {}); + const removeViewport = await adapter.onViewportChange(() => {}); + await adapter.ready(); + await adapter.setBackButtonVisible(true); + await adapter.setBackButtonVisible(false); + await adapter.setClosingConfirmation(true); + removeSettings(); + removeTheme(); + removeViewport(); + } +}); diff --git a/tests/privacy.test.mjs b/tests/privacy.test.mjs index 37b393c..4207d3c 100644 --- a/tests/privacy.test.mjs +++ b/tests/privacy.test.mjs @@ -7,6 +7,16 @@ import { join } from "node:path"; const app = readFileSync("src/app.js", "utf8"); +function privacySection(privacy, language, nextLanguage = null) { + const start = privacy.indexOf(`
", start); + assert.ok(start >= 0, `privacy notice must include ${language}`); + assert.ok(end > start, `privacy notice must close ${language}`); + return privacy.slice(start, end); +} + test("privacy notice describes account and gameplay processing in all locales", () => { assert.equal(existsSync("src/privacy.html"), true, "privacy notice source must exist"); const privacy = readFileSync("src/privacy.html", "utf8"); @@ -45,6 +55,48 @@ test("privacy notice describes account and gameplay processing in all locales", assert.match(privacy, /profile photo[^<]*(not public|не публику|不会公开)/i); }); +test("privacy notice explains Telegram Mini App identity processing in all locales", () => { + const privacy = readFileSync("src/privacy.html", "utf8"); + const sections = { + ru: privacySection(privacy, "ru", "en"), + en: privacySection(privacy, "en", "zh-CN"), + "zh-CN": privacySection(privacy, "zh-CN"), + }; + + assert.match(sections.ru, /подписанные данные запуска Telegram[\s\S]*Cloudflare Workers[\s\S]*проверки личности/i); + assert.match(sections.ru, /исходные данные запуска не сохраняются/i); + assert.match(sections.ru, /те же записи профиля/i); + + assert.match(sections.en, /signed Telegram launch data[\s\S]*Cloudflare Workers[\s\S]*identity validation/i); + assert.match(sections.en, /raw launch data is not persisted/i); + assert.match(sections.en, /same profile records/i); + + assert.match(sections["zh-CN"], /已签名的 Telegram 启动数据[\s\S]*Cloudflare Workers[\s\S]*身份验证/); + assert.match(sections["zh-CN"], /原始启动数据不会被持久保存/); + assert.match(sections["zh-CN"], /同一份档案记录/); +}); + +test("privacy notice distinguishes explicit Telegram consent from automatic Mini App validation", () => { + const privacy = readFileSync("src/privacy.html", "utf8"); + const sections = { + ru: privacySection(privacy, "ru", "en"), + en: privacySection(privacy, "en", "zh-CN"), + "zh-CN": privacySection(privacy, "zh-CN"), + }; + + assert.match(sections.ru, /на сайте и в установленном приложении[\s\S]*явное согласие[\s\S]*перед входом через Telegram/i); + assert.match(sections.ru, /Telegram Mini App[\s\S]*проверка личности начинается автоматически[\s\S]*подписанных данных запуска/i); + assert.match(sections.ru, /на сайте и в установленном приложении[\s\S]*без входа/i); + + assert.match(sections.en, /website and installed app[\s\S]*explicit consent[\s\S]*before Telegram sign-in/i); + assert.match(sections.en, /Telegram Mini App[\s\S]*identity validation starts automatically[\s\S]*signed launch data/i); + assert.match(sections.en, /without signing in[\s\S]*website and installed app/i); + + assert.match(sections["zh-CN"], /网站和已安装的应用[\s\S]*Telegram 登录前[\s\S]*明确同意/); + assert.match(sections["zh-CN"], /Telegram Mini App[\s\S]*已签名启动数据[\s\S]*身份验证会在打开时自动开始/); + assert.match(sections["zh-CN"], /网站和已安装应用[\s\S]*无需登录/); +}); + test("privacy notice is built and linked from Telegram authentication", () => { assert.match(app, /data-action=["']open-privacy["']/); assert.match(app, /href=["']\/agents-salvo\/privacy\.html["']/); diff --git a/tests/profile.test.mjs b/tests/profile.test.mjs index fe51044..263b85f 100644 --- a/tests/profile.test.mjs +++ b/tests/profile.test.mjs @@ -2,7 +2,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import worker from "../worker/index.js"; -import { getPlayerProfile, recordCompletedMatch } from "../worker/profile.js"; +import { + getPlayerProfile, + recordCompletedMatch, + recordOnlineReplayBatch, +} from "../worker/profile.js"; import { createSession } from "../worker/session.js"; const profileUser = { @@ -209,6 +213,87 @@ test("profile match endpoint rejects client-submitted online results", async () assert.deepEqual(await response.json(), { error: "Online results are recorded by the game server" }); }); +test("online replay batches require atomic storage and exact replay-linked participants", async () => { + const replay = { id: "expected-replay" }; + const prepareOnlyDb = { prepare() {} }; + const atomicDb = { + batch() { + assert.fail("invalid replay batches must not be written"); + }, + prepare() { + assert.fail("invalid replay batches must not prepare statements"); + }, + }; + + await assert.rejects( + recordOnlineReplayBatch(prepareOnlyDb, replay, []), + /Atomic replay storage is not configured/, + ); + await assert.rejects( + recordOnlineReplayBatch(atomicDb, replay, [ + { playerId: "p1", user: profileUser, payload: completedMatchPayload() }, + ]), + /Two online replay participants are required/, + ); + + const mismatchedPayload = { + ...completedMatchPayload(), + mode: "online", + replayId: "different-replay", + }; + await assert.rejects( + recordOnlineReplayBatch(atomicDb, replay, [ + { playerId: "p1", user: profileUser, payload: mismatchedPayload }, + { playerId: "p2", user: rivalUser, payload: mismatchedPayload }, + ]), + /Online replay match linkage is invalid/, + ); +}); + +test("online replay batches reject duplicate participant identities without changing stats or MMR", async () => { + const db = new MemoryD1(); + const { replay, playerMatches } = onlineReplayFixture({ + id: "duplicate-participants", + p2User: profileUser, + }); + const before = await getPlayerProfile(db, profileUser); + + await assert.rejects( + recordOnlineReplayBatch(db, replay, playerMatches), + /Online replay participant identities are invalid/, + ); + + const after = await getPlayerProfile(db, profileUser); + assert.deepEqual(after.summary, before.summary); + assert.deepEqual(after.rating, before.rating); + assert.equal(db.matches.length, 0); + assert.equal(db.battleReplays.size, 0); +}); + +test("online replay batches reject swapped participant identities without changing stats or MMR", async () => { + const db = new MemoryD1(); + const { replay, playerMatches } = onlineReplayFixture({ id: "swapped-participants" }); + [playerMatches[0].user, playerMatches[1].user] = [playerMatches[1].user, playerMatches[0].user]; + const before = await Promise.all([ + getPlayerProfile(db, profileUser), + getPlayerProfile(db, rivalUser), + ]); + + await assert.rejects( + recordOnlineReplayBatch(db, replay, playerMatches), + /Online replay participant identities are invalid/, + ); + + const after = await Promise.all([ + getPlayerProfile(db, profileUser), + getPlayerProfile(db, rivalUser), + ]); + assert.deepEqual(after.map((profile) => profile.summary), before.map((profile) => profile.summary)); + assert.deepEqual(after.map((profile) => profile.rating), before.map((profile) => profile.rating)); + assert.equal(db.matches.length, 0); + assert.equal(db.battleReplays.size, 0); +}); + test("profile endpoints fail generically when auth storage is unavailable", async () => { const token = await sessionToken(new MemoryD1(), profileUser); @@ -598,6 +683,43 @@ function completedMatchPayload() { }; } +function onlineReplayFixture({ id, p1User = profileUser, p2User = rivalUser }) { + const finishedAt = "2026-07-11T12:00:00.000Z"; + const replay = { + id, + p1UserKey: `${p1User.provider}:${p1User.id}`, + p2UserKey: `${p2User.provider}:${p2User.id}`, + presetId: "classic", + winnerId: "p1", + finishedAt, + payload: { version: 1 }, + }; + return { + replay, + playerMatches: [ + onlinePlayerMatch({ replay, playerId: "p1", user: p1User, opponent: p2User.name }), + onlinePlayerMatch({ replay, playerId: "p2", user: p2User, opponent: p1User.name }), + ], + }; +} + +function onlinePlayerMatch({ replay, playerId, user, opponent }) { + return { + playerId, + user, + payload: { + ...completedMatchPayload(), + id: `online:${replay.id}:${playerId}`, + mode: "online", + result: replay.winnerId === playerId ? "win" : "loss", + opponent, + winnerId: replay.winnerId, + playedAt: replay.finishedAt, + replayId: replay.id, + }, + }; +} + async function sessionToken(db, user) { return (await createSession(db, user)).token; } @@ -607,11 +729,34 @@ class MemoryD1 { this.users = new Map(); this.sessions = new Map(); this.matches = []; + this.battleReplays = new Map(); } prepare(sql) { return new MemoryStatement(this, sql); } + + async batch(statements) { + const snapshot = structuredClone({ + users: this.users, + sessions: this.sessions, + matches: this.matches, + battleReplays: this.battleReplays, + }); + try { + const results = []; + for (const statement of statements) { + results.push(await statement.run()); + } + return results; + } catch (error) { + this.users = snapshot.users; + this.sessions = snapshot.sessions; + this.matches = snapshot.matches; + this.battleReplays = snapshot.battleReplays; + throw error; + } + } } class MemoryStatement { @@ -712,6 +857,22 @@ class MemoryStatement { return { success: true }; } + if (this.sql.startsWith("INSERT OR IGNORE INTO battle_replays")) { + const [id, p1UserKey, p2UserKey, presetId, winnerId, finishedAt, dataJson] = this.params; + if (!this.db.battleReplays.has(id)) { + this.db.battleReplays.set(id, { + id, + p1_user_key: p1UserKey, + p2_user_key: p2UserKey, + preset_id: presetId, + winner_id: winnerId, + finished_at: finishedAt, + data_json: dataJson, + }); + } + return { success: true }; + } + throw new Error(`Unsupported run SQL: ${this.sql}`); } @@ -739,6 +900,52 @@ class MemoryStatement { } async rows() { + if (this.sql.startsWith("SELECT COUNT(*) AS online_matches")) { + const [targetId, targetPlayedAt, userKey, upperPlayedAt, upperId] = this.params; + const rows = this.db.matches.filter( + (match) => + match.user_key === userKey && + match.mode === "online" && + compareMatchTuple(match, upperPlayedAt, upperId) <= 0, + ); + return [{ + online_matches: rows.length, + online_wins: rows.filter((match) => match.result === "win").length, + online_losses: rows.filter((match) => match.result === "loss").length, + target_result: rows.find( + (match) => match.id === targetId && match.played_at === targetPlayedAt, + )?.result ?? null, + }]; + } + + if (this.sql.startsWith("SELECT played_at, id") && this.sql.includes("result = 'loss'")) { + const [userKey, upperPlayedAt, upperId] = this.params; + const latest = this.db.matches + .filter( + (match) => + match.user_key === userKey && + match.mode === "online" && + match.result === "loss" && + compareMatchTuple(match, upperPlayedAt, upperId) <= 0, + ) + .sort((first, second) => compareMatchTuple(second, first.played_at, first.id))[0]; + return latest ? [{ played_at: latest.played_at, id: latest.id }] : []; + } + + if (this.sql.startsWith("SELECT COUNT(*) AS current_streak")) { + const [userKey, lowerPlayedAt, lowerId, upperPlayedAt, upperId] = this.params; + return [{ + current_streak: this.db.matches.filter( + (match) => + match.user_key === userKey && + match.mode === "online" && + match.result === "win" && + compareMatchTuple(match, lowerPlayedAt, lowerId) > 0 && + compareMatchTuple(match, upperPlayedAt, upperId) <= 0, + ).length, + }]; + } + if (this.sql.startsWith("SELECT COUNT(*) AS total_matches")) { const rows = this.matchesForUser(); const wins = rows.filter((match) => match.result === "win"); @@ -873,3 +1080,7 @@ class MemoryStatement { return this.db.matches.filter((match) => match.user_key === this.params[0]); } } + +function compareMatchTuple(match, playedAt, id) { + return match.played_at.localeCompare(playedAt) || match.id.localeCompare(id); +} diff --git a/tests/session.test.mjs b/tests/session.test.mjs index 4727c57..ca338c4 100644 --- a/tests/session.test.mjs +++ b/tests/session.test.mjs @@ -101,6 +101,22 @@ test("createSession uses deterministic 32-byte randomness and stores only the to assert.equal(db.serializedRows().includes(token), false); }); +test("createSession rejects invalid entropy before writing auth state", async (t) => { + const db = memoryD1(t); + + for (const randomBytes of [ + () => new Uint8Array(31), + () => "not bytes", + ]) { + await assert.rejects( + createSession(db, telegramUser, { now, randomBytes }), + /Session randomness must contain 32 bytes/, + ); + } + assert.equal(db.queryOne("SELECT COUNT(*) AS count FROM auth_sessions").count, 0); + assert.equal(db.queryOne("SELECT COUNT(*) AS count FROM users").count, 0); +}); + test("resolveSession returns a normalized public user for an active session", async (t) => { const db = memoryD1(t); const { token } = await createSession(db, { ...telegramUser, id: 42 }, { @@ -185,6 +201,17 @@ test("revokeSession removes only the matching hashed token", async (t) => { assert.deepEqual(await resolveSession(db, second.token, { now: now + 1 }), telegramUser); }); +test("revokeSession ignores malformed tokens without accessing storage", async () => { + const inaccessibleDb = { + prepare() { + assert.fail("malformed tokens must not reach auth storage"); + }, + }; + + await revokeSession(inaccessibleDb, undefined); + await revokeSession(inaccessibleDb, "malformed.token"); +}); + test("resolveSession gives the same error for unknown and malformed tokens", async (t) => { const db = memoryD1(t); const invalidTokens = [ diff --git a/tests/stats.test.mjs b/tests/stats.test.mjs index f113aa9..9ea5f79 100644 --- a/tests/stats.test.mjs +++ b/tests/stats.test.mjs @@ -187,6 +187,38 @@ test("buildBattleReport recommends a training drill after low-accuracy losses", }); }); +test("buildBattleReport gives a steady baseline when the player did not fire", () => { + const report = buildBattleReport([], "p2", "p1"); + + assert.equal(report.result, "loss"); + assert.equal(report.player.shots, 0); + assert.deepEqual(report.coaching, { + diagnosisId: "steady", + focusId: "endgame", + drillId: "openingMap", + }); +}); + +test("buildBattleReport prioritizes finishing ships after a controlled loss", () => { + const log = [ + { playerId: "p1", result: "hit" }, + { playerId: "p1", result: "hit" }, + { playerId: "p1", result: "sunk" }, + { playerId: "p2", result: "hit" }, + { playerId: "p2", result: "sunk" }, + ]; + + const report = buildBattleReport(log, "p2", "p1"); + + assert.equal(report.player.accuracy, 100); + assert.equal(report.player.sunk, 1); + assert.deepEqual(report.coaching, { + diagnosisId: "finishShips", + focusId: "targetDiscipline", + drillId: "lineFinish", + }); +}); + test("buildBattleReport adds a tactical debrief for low-accuracy losses", () => { const log = [ { playerId: "p1", result: "miss" }, diff --git a/tests/tactics.test.mjs b/tests/tactics.test.mjs index b3601a2..d4c9eb3 100644 --- a/tests/tactics.test.mjs +++ b/tests/tactics.test.mjs @@ -51,6 +51,36 @@ test("analyzeTargetBoard reports salvo pressure when multiple shots remain", () assert.equal(analysis.availableTargets, 98); }); +test("analyzeTargetBoard recommends hunting after an ordinary miss", () => { + const board = { + ...createBoard(), + shots: [{ row: 0, col: 0, result: "miss" }], + }; + + const analysis = analyzeTargetBoard(board); + + assert.equal(analysis.recommendationId, "huntPattern"); + assert.equal(analysis.availableTargets, 99); +}); + +test("analyzeTargetBoard deduplicates priority cells shared by separate hits", () => { + const board = { + ...createBoard(), + shots: [ + { row: 4, col: 4, result: "hit", shipId: "left-ship" }, + { row: 4, col: 6, result: "hit", shipId: "right-ship" }, + ], + }; + + const analysis = analyzeTargetBoard(board); + + assert.equal(analysis.recommendationId, "finishDamaged"); + assert.equal( + analysis.priorityTargets.filter(({ row, col }) => row === 4 && col === 5).length, + 1, + ); +}); + test("analyzeTargetBoard shifts to endgame when few targets remain", () => { const board = { ...createBoard(4), diff --git a/tests/telegram-auth-client.test.mjs b/tests/telegram-auth-client.test.mjs index 8d9681d..0474d0c 100644 --- a/tests/telegram-auth-client.test.mjs +++ b/tests/telegram-auth-client.test.mjs @@ -228,6 +228,30 @@ test("HTTP, non-JSON, and oversized responses throw redacted status-bearing erro } }); +test("non-success responses cancel stalled bodies before request cleanup", async () => { + const caller = trackedAbortController(); + const stalled = stalledRejectedResponse(503); + const client = createTelegramAuthClient({ + workerUrl: "https://worker.test", + timeoutMs: 1_000, + fetcher: async () => stalled.response, + }); + + const pending = client.capability({ signal: caller.signal }); + const firstEvent = await Promise.race([ + stalled.cancelStarted.then(() => "cancelled"), + pending.then(() => "settled", () => "settled"), + ]); + + assert.equal(firstEvent, "cancelled"); + assert.deepEqual(caller.listenerCounts(), { added: 1, removed: 0 }); + stalled.releaseCancel(); + const error = await rejectedWithin(pending); + assertGenericClientError(error, 503); + assert.equal(stalled.cancelCalls(), 1); + assert.deepEqual(caller.listenerCounts(), { added: 1, removed: 1 }); +}); + test("network failures become stable generic errors and retain useful status", async () => { for (const sourceError of [ new Error("socket failure with provider-secret"), @@ -494,6 +518,29 @@ function stallingJsonResponse(secret, { cancelSecret = "" } = {}) { }; } +function stalledRejectedResponse(status) { + const started = deferred(); + const cancellation = deferred(); + let cancellations = 0; + return { + response: { + ok: false, + status, + headers: new Headers({ "Content-Type": "application/json" }), + body: { + cancel() { + cancellations += 1; + started.resolve(); + return cancellation.promise; + }, + }, + }, + cancelStarted: started.promise, + releaseCancel: cancellation.resolve, + cancelCalls: () => cancellations, + }; +} + function deferred() { let resolve; let reject; diff --git a/tests/telegram-build.test.mjs b/tests/telegram-build.test.mjs new file mode 100644 index 0000000..4521543 --- /dev/null +++ b/tests/telegram-build.test.mjs @@ -0,0 +1,2646 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + readdirSync, + renameSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { registerHooks } from "node:module"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = resolve(import.meta.dirname, ".."); +const appReference = /src="\.\/(app\.[a-f0-9]{10}\.js)"/g; +const telegramAppReference = /src="\.\.\/(app\.[a-f0-9]{10}\.js)"/g; +const styleReference = /href="\.\/(styles\.[a-f0-9]{10}\.css)"/g; +const telegramStyleReference = /href="\.\.\/(styles\.[a-f0-9]{10}\.css)"/g; + +function build({ buildId, cwd = root, output } = {}) { + const buildOutput = + output ?? mkdtempSync(join(tmpdir(), "salvo-telegram-build-")); + const result = spawnSync(process.execPath, ["scripts/build.mjs"], { + cwd, + encoding: "utf8", + env: buildEnvironment(buildOutput, buildId), + }); + return { output: buildOutput, result }; +} + +function buildAsync({ buildId, cwd = root, output }) { + return new Promise((resolveBuild, rejectBuild) => { + const child = spawn(process.execPath, ["scripts/build.mjs"], { + cwd, + env: buildEnvironment(output, buildId), + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", rejectBuild); + child.on("close", (status) => { + resolveBuild({ output, result: { status, stderr, stdout } }); + }); + }); +} + +function buildEnvironment(output, buildId) { + const env = { ...process.env, SALVO_BUILD_DIR: output }; + delete env.SALVO_BUILD_ID; + if (buildId !== undefined) { + env.SALVO_BUILD_ID = buildId; + } + return env; +} + +function assertBuildSucceeded(result) { + assert.equal(result.status, 0, result.stderr || result.stdout); +} + +function onlyMatch(source, pattern, label) { + const matches = [...source.matchAll(pattern)]; + assert.equal(matches.length, 1, `${label} must occur exactly once`); + return matches[0][1]; +} + +function sha256Prefix(source) { + return createHash("sha256").update(source).digest("hex").slice(0, 10); +} + +function readArtifacts(output) { + const web = readFileSync(join(output, "index.html"), "utf8"); + const app = onlyMatch(web, appReference, "web application reference"); + const stylesheet = onlyMatch(web, styleReference, "web stylesheet reference"); + const bundle = readFileSync(join(output, app)); + const map = onlyMatch( + bundle.toString("utf8"), + /sourceMappingURL=(app\.[a-f0-9]{10}\.js\.map)/g, + "application sourcemap reference", + ); + return { app, bundle, map, stylesheet, web }; +} + +function extractConfig(source) { + const config = source.match(/window\.SALVO_CONFIG = \{[\s\S]*?\n\s*\};/)?.[0]; + assert.notEqual(config, undefined, "SALVO_CONFIG is missing"); + return config; +} + +function count(source, exact) { + return source.split(exact).length - 1; +} + +function makeIsolatedProject() { + const isolatedRoot = mkdtempSync(join(tmpdir(), "salvo-build-fixture-")); + mkdirSync(join(isolatedRoot, "scripts")); + cpSync(join(root, "src"), join(isolatedRoot, "src"), { recursive: true }); + copyFileSync( + join(root, "scripts/build.mjs"), + join(isolatedRoot, "scripts/build.mjs"), + ); + const publicationModule = join(root, "scripts/build-publication.mjs"); + if (existsSync(publicationModule)) { + copyFileSync( + publicationModule, + join(isolatedRoot, "scripts/build-publication.mjs"), + ); + } + symlinkSync( + join(root, "node_modules"), + join(isolatedRoot, "node_modules"), + "dir", + ); + return isolatedRoot; +} + +function snapshotDirectory(directory) { + const files = []; + const visit = (current) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) { + visit(path); + } else { + files.push([ + relative(directory, path), + createHash("sha256").update(readFileSync(path)).digest("hex"), + ]); + } + } + }; + visit(directory); + return files.sort(([first], [second]) => first.localeCompare(second)); +} + +function assertNoBuildDebris(output) { + const name = basename(output); + const debris = readdirSync(dirname(output)).filter( + (entry) => + entry === `.${name}.lock` + || entry.startsWith(`.${name}.lock.`) + || entry.startsWith(`.${name}.stage-`) + || entry === `.${name}.backup` + || entry.startsWith(`.${name}.backup-`), + ); + assert.deepEqual(debris, []); +} + +const publicationFsFault = { current: null }; +let publicationModulePromise; + +async function loadPublicationModule() { + const path = join(root, "scripts/build-publication.mjs"); + assert.equal( + existsSync(path), + true, + "build publication recovery module is missing", + ); + publicationModulePromise ??= importWithFsFault(path, publicationFsFault); + return publicationModulePromise; +} + +let faultingImportSequence = 0; + +async function importWithFsFault(path, fault) { + faultingImportSequence += 1; + const faultKey = `__salvoBuildFsFault${faultingImportSequence}`; + const targetUrl = new URL(pathToFileURL(path)); + globalThis[faultKey] = fault; + + const wrapperSource = ` + import * as real from "node:fs/promises"; + const fault = globalThis[${JSON.stringify(faultKey)}]; + const call = (name, args) => typeof (fault.current ?? fault)[name] === "function" + ? (fault.current ?? fault)[name](real, ...args) + : real[name](...args); + export const cp = (...args) => call("cp", args); + export const lstat = (...args) => call("lstat", args); + export const mkdir = (...args) => call("mkdir", args); + export const mkdtemp = (...args) => call("mkdtemp", args); + export const open = (...args) => call("open", args); + export const readFile = (...args) => call("readFile", args); + export const readdir = (...args) => call("readdir", args); + export const realpath = (...args) => call("realpath", args); + export const rename = (...args) => call("rename", args); + export const rm = (...args) => call("rm", args); + export const writeFile = (...args) => call("writeFile", args); + `; + const wrapperUrl = `data:text/javascript;base64,${Buffer.from(wrapperSource).toString("base64")}`; + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if ( + specifier === "node:fs/promises" + && context.parentURL === targetUrl.href + ) { + return { shortCircuit: true, url: wrapperUrl }; + } + return nextResolve(specifier, context); + }, + }); + + try { + return await import(targetUrl.href); + } finally { + hooks.deregister(); + delete globalThis[faultKey]; + } +} + +async function withPublicationFsFault(fault, callback) { + const publication = await loadPublicationModule(); + assert.equal(publicationFsFault.current, null); + publicationFsFault.current = fault; + try { + return await callback(publication); + } finally { + publicationFsFault.current = null; + } +} + +function filesystemError(code, message = `injected ${code} filesystem failure`) { + return Object.assign(new Error(message), { code }); +} + +function writeOwner(path, owner) { + writeFileSync(path, `${JSON.stringify(owner)}\n`, "utf8"); +} + +function deadOwner(token) { + return { + pid: 2_147_483_647, + timestamp: Date.now(), + token, + }; +} + +function liveOwner(token) { + return { + pid: process.pid, + timestamp: Date.now(), + token, + }; +} + +function createDeadLock(paths, token) { + mkdirSync(paths.lockPath); + const owner = deadOwner(token); + writeOwner(paths.lockOwnerPath, owner); + return owner; +} + +function createDeadRecoveryClaim(paths, token) { + mkdirSync(paths.lockRecoveryPath); + const owner = deadOwner(token); + writeOwner(join(paths.lockRecoveryPath, "owner.json"), owner); + return owner; +} + +test("build emits web and Telegram shells with one shared hashed app and stylesheet", () => { + const { output, result } = build(); + try { + assertBuildSucceeded(result); + assert.equal(existsSync(join(output, "index.html")), true); + assert.equal(existsSync(join(output, "telegram/index.html")), true); + + const web = readFileSync(join(output, "index.html"), "utf8"); + const telegram = readFileSync(join(output, "telegram/index.html"), "utf8"); + const webApp = onlyMatch(web, appReference, "web application reference"); + const telegramApp = onlyMatch( + telegram, + telegramAppReference, + "Telegram application reference", + ); + const webStyle = onlyMatch(web, styleReference, "web stylesheet reference"); + const telegramStyle = onlyMatch( + telegram, + telegramStyleReference, + "Telegram stylesheet reference", + ); + + assert.equal(webApp, telegramApp); + assert.equal(webStyle, telegramStyle); + assert.match(web, /]*\bdata-runtime="web"[^>]*>/); + assert.match(telegram, /]*\bdata-runtime="telegram"[^>]*>/); + assert.doesNotMatch(web, /telegram-web-app\.js/); + assert.equal( + count(telegram, "https://telegram.org/js/telegram-web-app.js?63"), + 1, + ); + assert.equal( + telegram.indexOf("https://telegram.org/js/telegram-web-app.js?63") + < telegram.indexOf(`../${telegramApp}`), + true, + "Telegram SDK must load before the shared application module", + ); + + const webMetadata = [...web.matchAll(/]*>/g)].map( + (match) => match[0], + ); + const telegramMetadata = [...telegram.matchAll(/]*>/g)].map( + (match) => match[0], + ); + assert.deepEqual(telegramMetadata, webMetadata); + assert.equal(extractConfig(telegram), extractConfig(web)); + assert.match(web, /href="\.\/favicon\.svg"/); + assert.match(web, /href="\.\/manifest\.webmanifest"/); + assert.match(telegram, /href="\.\.\/favicon\.svg"/); + assert.match(telegram, /href="\.\.\/manifest\.webmanifest"/); + + const rootFiles = readdirSync(output); + assert.deepEqual(rootFiles.filter((file) => /^app\..+\.js$/.test(file)), [ + webApp, + ]); + assert.deepEqual( + rootFiles.filter((file) => /^styles\..+\.css$/.test(file)), + [webStyle], + ); + assert.equal(existsSync(join(output, "app.js")), false); + assert.equal(existsSync(join(output, "styles.css")), false); + } finally { + rmSync(output, { recursive: true, force: true }); + } +}); + +test("bundle, sourcemap, and stylesheet names hash their final emitted bytes", () => { + const { output, result } = build(); + try { + assertBuildSucceeded(result); + const { app, bundle, map, stylesheet } = readArtifacts(output); + const appHash = app.match(/^app\.([a-f0-9]{10})\.js$/)?.[1]; + const mapHash = map.match(/^app\.([a-f0-9]{10})\.js\.map$/)?.[1]; + const styleHash = stylesheet.match(/^styles\.([a-f0-9]{10})\.css$/)?.[1]; + + assert.equal(sha256Prefix(bundle), appHash); + assert.equal(sha256Prefix(readFileSync(join(output, map))), mapHash); + assert.equal(sha256Prefix(readFileSync(join(output, stylesheet))), styleHash); + assert.equal(existsSync(join(output, map)), true); + assert.equal(existsSync(join(output, "app.js.map")), false); + assert.equal(count(bundle.toString("utf8"), `sourceMappingURL=${map}`), 1); + assert.doesNotMatch(bundle.toString("utf8"), /sourceMappingURL=app\.js\.map/); + } finally { + rmSync(output, { recursive: true, force: true }); + } +}); + +test("a sourcemap-only source change changes the map URL and final bundle hash", () => { + const isolatedRoot = makeIsolatedProject(); + const firstOutput = join(isolatedRoot, "first-dist"); + const secondOutput = join(isolatedRoot, "second-dist"); + try { + const first = build({ cwd: isolatedRoot, output: firstOutput }); + assertBuildSucceeded(first.result); + const appPath = join(isolatedRoot, "src/app.js"); + writeFileSync( + appPath, + `${readFileSync(appPath, "utf8")}\n// Sourcemap-only fixture change.\n`, + ); + const second = build({ cwd: isolatedRoot, output: secondOutput }); + assertBuildSucceeded(second.result); + + const firstArtifacts = readArtifacts(firstOutput); + const secondArtifacts = readArtifacts(secondOutput); + assert.notEqual(secondArtifacts.map, firstArtifacts.map); + assert.notEqual(secondArtifacts.app, firstArtifacts.app); + assert.equal( + sha256Prefix(secondArtifacts.bundle), + secondArtifacts.app.match(/^app\.([a-f0-9]{10})\.js$/)?.[1], + ); + } finally { + rmSync(isolatedRoot, { recursive: true, force: true }); + } +}); + +test("Telegram runtime resolves visual and audio assets beside the shared bundle", async () => { + const { output, result } = build(); + try { + assertBuildSucceeded(result); + const telegram = readFileSync(join(output, "telegram/index.html"), "utf8"); + const app = onlyMatch( + telegram, + telegramAppReference, + "Telegram application reference", + ); + const telegramDocumentUrl = new URL("https://salvo.example/telegram/index.html"); + const sharedBundleUrl = new URL(`../${app}`, telegramDocumentUrl); + assert.equal(sharedBundleUrl.pathname, `/${app}`); + + const builtApp = await import( + `${pathToFileURL(join(output, app)).href}?asset-resolution=${Date.now()}` + ); + assert.equal(typeof builtApp.assetUrl, "function"); + const visualAssets = [ + "./assets/salvo-board-action.png", + "./assets/images/backgrounds/main-menu-hero-dark-no-ui.png", + "./assets/images/ships/ship-4-h-normal.png", + "./assets/images/effects/hit-explosion-smoke.png", + ]; + for (const source of visualAssets) { + assert.equal(new URL(source, sharedBundleUrl).pathname, `/${source.slice(2)}`); + assert.equal( + fileURLToPath(builtApp.assetUrl(source)), + join(realpathSync(output), source.slice(2)), + ); + } + assert.deepEqual( + builtApp.menuMusicTracks.map((source) => fileURLToPath(source)), + [ + join(realpathSync(output), "assets/audio/menu-loop.mp3"), + join(realpathSync(output), "assets/audio/menu-loop-v2.mp3"), + ], + ); + + const appSource = readFileSync(join(root, "src/app.js"), "utf8"); + const audioSource = readFileSync(join(root, "src/core/audio.js"), "utf8"); + assert.match(appSource, /assetUrl\("\.\/assets\/salvo-board-action\.png"\)/); + assert.match(appSource, /assetUrl\(\s*`\.\/assets\/images\/ships\//); + assert.match(appSource, /assetUrl\("\.\/assets\/images\/effects\//); + assert.match(audioSource, /assetUrl\("\.\/assets\/audio\/menu-loop\.mp3"\)/); + } finally { + rmSync(output, { recursive: true, force: true }); + } +}); + +test("build hashes are deterministic and independent of the shell build ID", () => { + const first = build(); + const second = build({ buildId: "release_2026.07-17" }); + try { + assertBuildSucceeded(first.result); + assertBuildSucceeded(second.result); + const firstWeb = readFileSync(join(first.output, "index.html"), "utf8"); + const secondWeb = readFileSync(join(second.output, "index.html"), "utf8"); + const firstApp = onlyMatch(firstWeb, appReference, "first application reference"); + const secondApp = onlyMatch( + secondWeb, + appReference, + "second application reference", + ); + const firstStyle = onlyMatch( + firstWeb, + styleReference, + "first stylesheet reference", + ); + const secondStyle = onlyMatch( + secondWeb, + styleReference, + "second stylesheet reference", + ); + + assert.equal(secondApp, firstApp); + assert.equal(secondStyle, firstStyle); + assert.deepEqual( + readFileSync(join(second.output, secondApp)), + readFileSync(join(first.output, firstApp)), + ); + assert.deepEqual( + readFileSync(join(second.output, secondStyle)), + readFileSync(join(first.output, firstStyle)), + ); + assert.deepEqual( + readFileSync(join(second.output, readArtifacts(second.output).map)), + readFileSync(join(first.output, readArtifacts(first.output).map)), + ); + } finally { + rmSync(first.output, { recursive: true, force: true }); + rmSync(second.output, { recursive: true, force: true }); + } +}); + +test("build ID defaults to dev and replaces the exact marker once in both shells", () => { + for (const buildId of [undefined, "release_2026.07-17"]) { + const expected = buildId ?? "dev"; + const { output, result } = build({ buildId }); + try { + assertBuildSucceeded(result); + for (const shell of ["index.html", "telegram/index.html"]) { + const html = readFileSync(join(output, shell), "utf8"); + assert.equal(count(html, `buildId: "${expected}"`), 1, shell); + if (buildId !== undefined) { + assert.equal(count(html, 'buildId: "dev"'), 0, shell); + } + } + + const appBundles = readdirSync(output).filter((file) => + /^app\.[a-f0-9]{10}\.js$/u.test(file), + ); + assert.equal(appBundles.length, 1); + } finally { + rmSync(output, { recursive: true, force: true }); + } + } +}); + +test("build rejects supplied invalid build IDs", () => { + for (const buildId of [ + "", + "contains spaces", + "a".repeat(65), + "release/1", + "r\u00e9lease", + ]) { + const { output, result } = build({ buildId }); + try { + assert.notEqual( + result.status, + 0, + `build ID ${JSON.stringify(buildId)} was accepted`, + ); + assert.match(result.stderr, /SALVO_BUILD_ID/); + } finally { + rmSync(output, { recursive: true, force: true }); + } + } +}); + +test("build rejects a temporary symlink destination that resolves outside temp", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "salvo-symlink-build-")); + const externalRoot = mkdtempSync(join(root, ".salvo-external-build-")); + const externalOutput = join(externalRoot, "published"); + const sentinel = join(externalOutput, "keep.txt"); + mkdirSync(externalOutput); + writeFileSync(sentinel, "do not delete", "utf8"); + symlinkSync(externalRoot, join(temporaryRoot, "redirect"), "dir"); + try { + const { result } = build({ + output: join(temporaryRoot, "redirect", "published"), + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /temporary directory/i); + assert.equal(readFileSync(sentinel, "utf8"), "do not delete"); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + rmSync(externalRoot, { recursive: true, force: true }); + } +}); + +test("build rejects a cyclic destination symlink without creating state", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "salvo-cyclic-build-")); + const output = join(temporaryRoot, "loop"); + symlinkSync("loop", output); + + try { + const { result } = build({ output }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /ELOOP|too many symbolic links/i); + assert.deepEqual(readdirSync(temporaryRoot), ["loop"]); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("a shell validation failure removes its stage and preserves prior output", async () => { + const output = join(root, "dist"); + const outputExisted = existsSync(output); + if (!outputExisted) { + mkdirSync(output); + writeFileSync(join(output, "prior.txt"), "prior", "utf8"); + } + const before = snapshotDirectory(output); + const previousBuildDir = process.env.SALVO_BUILD_DIR; + delete process.env.SALVO_BUILD_DIR; + + try { + await loadPublicationModule(); + await assert.rejects( + importWithFsFault(join(root, "scripts/build.mjs"), { + async readFile(real, path, options) { + const source = await real.readFile(path, options); + if ( + basename(path) === "index.html" + && basename(dirname(path)).startsWith(".dist.stage-") + ) { + return source.replace( + '', + '', + ); + } + return source; + }, + }), + /application reference must have exactly one occurrence/i, + ); + assert.deepEqual(snapshotDirectory(output), before); + assertNoBuildDebris(output); + } finally { + if (previousBuildDir === undefined) { + delete process.env.SALVO_BUILD_DIR; + } else { + process.env.SALVO_BUILD_DIR = previousBuildDir; + } + if (!outputExisted) { + rmSync(output, { recursive: true, force: true }); + } + } +}); + +test("build rejects a symlinked lock without touching its external target", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "salvo-lock-symlink-")); + const externalRoot = mkdtempSync(join(root, ".salvo-external-lock-")); + const output = join(temporaryRoot, "dist"); + const lockPath = join(temporaryRoot, ".dist.lock"); + writeFileSync(join(externalRoot, "keep.txt"), "do not modify", "utf8"); + writeFileSync( + join(externalRoot, "owner.json"), + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "external-dead-owner", + }), + "utf8", + ); + const before = snapshotDirectory(externalRoot); + symlinkSync(externalRoot, lockPath, "dir"); + try { + const { result } = build({ output }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Build lock path must be a real directory/); + assert.equal(existsSync(join(externalRoot, ".recovery")), false); + assert.deepEqual(snapshotDirectory(externalRoot), before); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + rmSync(externalRoot, { recursive: true, force: true }); + } +}); + +test("build rejects a non-directory lock with a stable error", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "salvo-lock-file-")); + const output = join(temporaryRoot, "dist"); + const lockPath = join(temporaryRoot, ".dist.lock"); + writeFileSync(lockPath, "unexpected lock node", "utf8"); + try { + const { result } = build({ output }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Build lock path must be a real directory/); + assert.equal(readFileSync(lockPath, "utf8"), "unexpected lock node"); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("build rejects a symlinked backup without touching its target", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "salvo-backup-symlink-")); + const externalRoot = mkdtempSync(join(root, ".salvo-external-backup-")); + const output = join(temporaryRoot, "dist"); + writeFileSync(join(externalRoot, "keep.txt"), "do not modify", "utf8"); + const before = snapshotDirectory(externalRoot); + symlinkSync(externalRoot, join(temporaryRoot, ".dist.backup"), "dir"); + try { + const { result } = build({ output }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Build backup path must be a real directory/); + assert.deepEqual(snapshotDirectory(externalRoot), before); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + rmSync(externalRoot, { recursive: true, force: true }); + } +}); + +test("build rejects a symlinked abandoned stage without touching its target", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "salvo-stage-symlink-")); + const externalRoot = mkdtempSync(join(root, ".salvo-external-stage-")); + const output = join(temporaryRoot, "dist"); + writeFileSync(join(externalRoot, "keep.txt"), "do not modify", "utf8"); + const before = snapshotDirectory(externalRoot); + symlinkSync( + externalRoot, + join(temporaryRoot, ".dist.stage-untrusted"), + "dir", + ); + try { + const { result } = build({ output }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Build stage path must be a real directory/); + assert.deepEqual(snapshotDirectory(externalRoot), before); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + rmSync(externalRoot, { recursive: true, force: true }); + } +}); + +test("build rejects a symlinked recovery quarantine without touching its target", () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), "salvo-quarantine-symlink-")); + const externalRoot = mkdtempSync(join(root, ".salvo-external-quarantine-")); + const output = join(temporaryRoot, "dist"); + writeFileSync(join(externalRoot, "keep.txt"), "do not modify", "utf8"); + const before = snapshotDirectory(externalRoot); + symlinkSync( + externalRoot, + join(temporaryRoot, ".dist.lock.recovery-quarantine-untrusted"), + "dir", + ); + try { + const { result } = build({ output }); + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /Build recovery quarantine path must be a real directory/, + ); + assert.deepEqual(snapshotDirectory(externalRoot), before); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + rmSync(externalRoot, { recursive: true, force: true }); + } +}); + +test("a failed staged build preserves the previously published output", () => { + const isolatedRoot = makeIsolatedProject(); + const output = join(isolatedRoot, "dist"); + try { + const first = build({ cwd: isolatedRoot, output }); + assertBuildSucceeded(first.result); + const before = snapshotDirectory(output); + const indexPath = join(isolatedRoot, "src/index.html"); + writeFileSync( + indexPath, + readFileSync(indexPath, "utf8").replace( + '', + '', + ), + ); + + const failed = build({ cwd: isolatedRoot, output }); + assert.notEqual(failed.result.status, 0); + assert.match(failed.result.stderr, /exactly one occurrence/i); + assert.deepEqual(snapshotDirectory(output), before); + assertNoBuildDebris(output); + } finally { + rmSync(isolatedRoot, { recursive: true, force: true }); + } +}); + +test("an interrupted rename gap restores prior output before replacement", () => { + const isolatedRoot = makeIsolatedProject(); + const output = join(isolatedRoot, "dist"); + const backup = join(isolatedRoot, ".dist.backup"); + const lock = join(isolatedRoot, ".dist.lock"); + const indexPath = join(isolatedRoot, "src/index.html"); + try { + const first = build({ + buildId: "before-interruption", + cwd: isolatedRoot, + output, + }); + assertBuildSucceeded(first.result); + const before = snapshotDirectory(output); + + const publicationModule = pathToFileURL( + join(isolatedRoot, "scripts/build-publication.mjs"), + ).href; + const interrupted = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` + import { mkdir, rename, writeFile } from "node:fs/promises"; + import { join } from "node:path"; + import { + acquireBuildLock, + buildStatePaths, + publishBuild, + reconcileBuildState, + } from ${JSON.stringify(publicationModule)}; + + const output = ${JSON.stringify(output)}; + const stage = ${JSON.stringify(join(isolatedRoot, ".dist.stage-interrupted"))}; + const lock = await acquireBuildLock(output); + await reconcileBuildState(output, lock); + await mkdir(stage); + await writeFile(join(stage, "partial.txt"), "partial"); + const { backupPath } = buildStatePaths(output); + await publishBuild(stage, output, { + async renamePath(from, to) { + await rename(from, to); + if (from === output && to === backupPath) { + process.exit(73); + } + }, + }); + `, + ], + { encoding: "utf8" }, + ); + assert.equal(interrupted.status, 73, interrupted.stderr); + assert.equal(existsSync(output), false); + assert.equal(existsSync(backup), true); + assert.equal(existsSync(lock), true); + + const validIndex = readFileSync(indexPath, "utf8"); + writeFileSync( + indexPath, + validIndex.replace( + '', + '', + ), + ); + + // Consumers wait for the command result; a failed recovery build must leave + // the previous complete output restored and observable. + const recoveryBuild = build({ cwd: isolatedRoot, output }); + assert.notEqual(recoveryBuild.result.status, 0); + assert.match(recoveryBuild.result.stderr, /exactly one occurrence/i); + assert.equal( + existsSync(output), + true, + "prior output must be restored before new build validation", + ); + assert.deepEqual(snapshotDirectory(output), before); + assertNoBuildDebris(output); + + writeFileSync(indexPath, validIndex); + const replacement = build({ + buildId: "after-recovery", + cwd: isolatedRoot, + output, + }); + assertBuildSucceeded(replacement.result); + assert.match( + readFileSync(join(output, "index.html"), "utf8"), + /buildId: "after-recovery"/, + ); + assertNoBuildDebris(output); + } finally { + rmSync(isolatedRoot, { recursive: true, force: true }); + } +}); + +test("a durable lock owner write failure removes its candidate", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-owner-write-failure-")); + const output = join(parent, "dist"); + const failure = filesystemError("EACCES", "injected owner write denial"); + let candidatePath; + + try { + await withPublicationFsFault( + { + open(real, path, ...args) { + if (basename(dirname(path)).startsWith(".dist.lock.candidate-")) { + candidatePath = dirname(path); + throw failure; + } + return real.open(path, ...args); + }, + }, + async ({ acquireBuildLock }) => { + await assert.rejects( + acquireBuildLock(output), + (error) => error === failure, + ); + }, + ); + assert.equal(existsSync(candidatePath), false); + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("a vanished durable-owner candidate preserves the write failure", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-owner-candidate-vanished-")); + const output = join(parent, "dist"); + const failure = filesystemError("EACCES", "injected owner write denial"); + let candidatePath; + + try { + await withPublicationFsFault( + { + async open(real, path, ...args) { + if (basename(dirname(path)).startsWith(".dist.lock.candidate-")) { + candidatePath = dirname(path); + await real.rm(candidatePath, { recursive: true, force: true }); + throw failure; + } + return real.open(path, ...args); + }, + }, + async ({ acquireBuildLock }) => { + await assert.rejects( + acquireBuildLock(output), + (error) => error === failure, + ); + }, + ); + assert.equal(existsSync(candidatePath), false); + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("durable-owner cleanup refuses a replacement candidate inode", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-owner-candidate-replaced-")); + const output = join(parent, "dist"); + const failure = filesystemError("EACCES", "injected owner write denial"); + let candidatePath; + let retiredPath; + + try { + await withPublicationFsFault( + { + async open(real, path, ...args) { + if (basename(dirname(path)).startsWith(".dist.lock.candidate-")) { + candidatePath = dirname(path); + retiredPath = `${candidatePath}.retired`; + await real.rename(candidatePath, retiredPath); + await real.mkdir(candidatePath); + throw failure; + } + return real.open(path, ...args); + }, + }, + async ({ acquireBuildLock }) => { + await assert.rejects( + acquireBuildLock(output), + /Build lock candidate ownership changed before cleanup/, + ); + }, + ); + assert.equal(existsSync(candidatePath), true); + assert.equal(existsSync(retiredPath), true); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("stale recovery yields when the inspected lock disappears", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-lock-disappears-")); + const output = join(parent, "dist"); + let lock; + + try { + await withPublicationFsFault( + { + removed: false, + async readFile(real, path, ...args) { + const source = await real.readFile(path, ...args); + if (!this.removed && path === this.ownerPath) { + this.removed = true; + await real.rm(this.lockPath, { recursive: true, force: true }); + } + return source; + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-disappearance"); + publicationFsFault.current.lockPath = paths.lockPath; + publicationFsFault.current.ownerPath = paths.lockOwnerPath; + lock = await publication.acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 250, + }); + assert.notEqual(lock.owner.token, "dead-lock-before-disappearance"); + await publication.releaseBuildLock(lock); + lock = null; + }, + ); + assertNoBuildDebris(output); + } finally { + if (lock) { + const { releaseBuildLock } = await loadPublicationModule(); + await releaseBuildLock(lock).catch(() => {}); + } + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("stale recovery preserves a replacement lock found after inspection", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-lock-replaced-after-read-")); + const output = join(parent, "dist"); + const replacement = liveOwner("replacement-after-stale-inspection"); + + try { + await withPublicationFsFault( + { + replaced: false, + async readFile(real, path, ...args) { + const source = await real.readFile(path, ...args); + if (!this.replaced && path === this.ownerPath) { + this.replaced = true; + await real.rename(this.lockPath, this.retiredPath); + await real.mkdir(this.lockPath); + await real.writeFile(this.ownerPath, JSON.stringify(replacement)); + } + return source; + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-replacement"); + publicationFsFault.current.lockPath = paths.lockPath; + publicationFsFault.current.ownerPath = paths.lockOwnerPath; + publicationFsFault.current.retiredPath = `${paths.lockPath}.retired`; + await assert.rejects( + publication.acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.deepEqual( + JSON.parse(readFileSync(paths.lockOwnerPath, "utf8")), + replacement, + ); + }, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("recovery retries when its prepared candidate disappears", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-recovery-candidate-missing-")); + const output = join(parent, "dist"); + let lock; + + try { + await withPublicationFsFault( + { + injected: false, + async rename(real, from, to) { + if (!this.injected && from.startsWith(this.candidatePrefix)) { + this.injected = true; + await real.rm(from, { recursive: true, force: true }); + throw filesystemError("ENOENT"); + } + return real.rename(from, to); + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-candidate-loss"); + publicationFsFault.current.candidatePrefix = + paths.lockRecoveryCandidatePrefix; + lock = await publication.acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 250, + }); + await publication.releaseBuildLock(lock); + lock = null; + }, + ); + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("recovery propagates a non-contention candidate publication failure", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-recovery-publish-denied-")); + const output = join(parent, "dist"); + const failure = filesystemError("EACCES", "injected recovery publication denial"); + + try { + await withPublicationFsFault( + { + rename(real, from, to) { + if (from.startsWith(this.candidatePrefix)) { + throw failure; + } + return real.rename(from, to); + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-recovery-denial"); + publicationFsFault.current.candidatePrefix = + paths.lockRecoveryCandidatePrefix; + await assert.rejects( + publication.acquireBuildLock(output), + (error) => error === failure, + ); + assert.equal(existsSync(paths.lockRecoveryPath), false); + assert.equal( + readdirSync(parent).some((entry) => + entry.startsWith(".dist.lock.recovery-candidate-")), + false, + ); + }, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("recovery yields to a winner after candidate rename contention", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-recovery-rename-winner-")); + const output = join(parent, "dist"); + const winner = liveOwner("recovery-rename-winner"); + + try { + await withPublicationFsFault( + { + injected: false, + async rename(real, from, to) { + if (!this.injected && from.startsWith(this.candidatePrefix)) { + this.injected = true; + await real.mkdir(to); + await real.writeFile(join(to, "owner.json"), JSON.stringify(winner)); + throw filesystemError("EEXIST"); + } + return real.rename(from, to); + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-recovery-winner"); + publicationFsFault.current.candidatePrefix = + paths.lockRecoveryCandidatePrefix; + await assert.rejects( + publication.acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.deepEqual( + JSON.parse( + readFileSync(join(paths.lockRecoveryPath, "owner.json"), "utf8"), + ), + winner, + ); + }, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("abandoned recovery quarantine retries a generated-name collision", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-quarantine-collision-")); + const output = join(parent, "dist"); + let lock; + + try { + await withPublicationFsFault( + { + injected: false, + rename(real, from, to) { + if ( + !this.injected + && from === this.recoveryPath + && to.startsWith(this.quarantinePrefix) + ) { + this.injected = true; + throw filesystemError("EEXIST"); + } + return real.rename(from, to); + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-with-colliding-quarantine"); + createDeadRecoveryClaim(paths, "dead-recovery-with-name-collision"); + publicationFsFault.current.recoveryPath = paths.lockRecoveryPath; + publicationFsFault.current.quarantinePrefix = + paths.lockRecoveryQuarantinePrefix; + lock = await publication.acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 250, + }); + await publication.releaseBuildLock(lock); + lock = null; + }, + ); + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("abandoned recovery quarantine propagates an unexpected rename failure", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-quarantine-denied-")); + const output = join(parent, "dist"); + const failure = filesystemError("EACCES", "injected quarantine rename denial"); + + try { + await withPublicationFsFault( + { + rename(real, from, to) { + if ( + from === this.recoveryPath + && to.startsWith(this.quarantinePrefix) + ) { + throw failure; + } + return real.rename(from, to); + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-quarantine-denial"); + createDeadRecoveryClaim(paths, "dead-recovery-before-quarantine-denial"); + publicationFsFault.current.recoveryPath = paths.lockRecoveryPath; + publicationFsFault.current.quarantinePrefix = + paths.lockRecoveryQuarantinePrefix; + await assert.rejects( + publication.acquireBuildLock(output), + (error) => error === failure, + ); + assert.equal(existsSync(paths.lockRecoveryPath), true); + }, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("a recovery claim refreshed during quarantine is restored", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-quarantine-refresh-")); + const output = join(parent, "dist"); + const refreshed = liveOwner("refreshed-during-quarantine"); + + try { + await withPublicationFsFault( + { + async rename(real, from, to) { + await real.rename(from, to); + if ( + from === this.recoveryPath + && to.startsWith(this.quarantinePrefix) + ) { + await real.writeFile(join(to, "owner.json"), JSON.stringify(refreshed)); + } + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-claim-refresh"); + createDeadRecoveryClaim(paths, "dead-recovery-before-refresh"); + publicationFsFault.current.recoveryPath = paths.lockRecoveryPath; + publicationFsFault.current.quarantinePrefix = + paths.lockRecoveryQuarantinePrefix; + await assert.rejects( + publication.acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.deepEqual( + JSON.parse( + readFileSync(join(paths.lockRecoveryPath, "owner.json"), "utf8"), + ), + refreshed, + ); + }, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("recovery continues when a refreshed quarantine disappears before restore", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-quarantine-restore-missing-")); + const output = join(parent, "dist"); + let lock; + + try { + await withPublicationFsFault( + { + async rename(real, from, to) { + if ( + from === this.recoveryPath + && to.startsWith(this.quarantinePrefix) + ) { + await real.rename(from, to); + await real.writeFile( + join(to, "owner.json"), + JSON.stringify(liveOwner("refresh-before-disappearance")), + ); + return; + } + if ( + from.startsWith(this.quarantinePrefix) + && to === this.recoveryPath + ) { + await real.rm(from, { recursive: true, force: true }); + throw filesystemError("ENOENT"); + } + return real.rename(from, to); + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-missing-restore"); + createDeadRecoveryClaim(paths, "dead-recovery-before-missing-restore"); + publicationFsFault.current.recoveryPath = paths.lockRecoveryPath; + publicationFsFault.current.quarantinePrefix = + paths.lockRecoveryQuarantinePrefix; + lock = await publication.acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 250, + }); + await publication.releaseBuildLock(lock); + lock = null; + }, + ); + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("stale recovery preserves a replacement lock installed after its claim", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-lock-replaced-after-claim-")); + const output = join(parent, "dist"); + const replacement = liveOwner("replacement-after-recovery-claim"); + + try { + await withPublicationFsFault( + { + claimPublished: false, + replaced: false, + async lstat(real, path, ...args) { + const stats = await real.lstat(path, ...args); + if (this.claimPublished && !this.replaced && path === this.lockPath) { + this.replaced = true; + await real.rename(this.lockPath, this.retiredPath); + await real.mkdir(this.lockPath); + await real.writeFile(this.ownerPath, JSON.stringify(replacement)); + } + return stats; + }, + async rename(real, from, to) { + await real.rename(from, to); + if (from.startsWith(this.candidatePrefix) && to === this.recoveryPath) { + this.claimPublished = true; + } + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-post-claim-replacement"); + Object.assign(publicationFsFault.current, { + candidatePrefix: paths.lockRecoveryCandidatePrefix, + lockPath: paths.lockPath, + ownerPath: paths.lockOwnerPath, + recoveryPath: paths.lockRecoveryPath, + retiredPath: `${paths.lockPath}.retired`, + }); + await assert.rejects( + publication.acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.deepEqual( + JSON.parse(readFileSync(paths.lockOwnerPath, "utf8")), + replacement, + ); + assert.equal(existsSync(paths.lockRecoveryPath), false); + }, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("stale recovery tolerates a lock removed after its claim", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-lock-removed-after-claim-")); + const output = join(parent, "dist"); + let lock; + + try { + await withPublicationFsFault( + { + claimPublished: false, + removed: false, + async lstat(real, path, ...args) { + const stats = await real.lstat(path, ...args); + if (this.claimPublished && !this.removed && path === this.lockPath) { + this.removed = true; + await real.rm(this.lockPath, { recursive: true, force: true }); + } + return stats; + }, + async rename(real, from, to) { + await real.rename(from, to); + if (from.startsWith(this.candidatePrefix) && to === this.recoveryPath) { + this.claimPublished = true; + } + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "dead-lock-before-post-claim-removal"); + Object.assign(publicationFsFault.current, { + candidatePrefix: paths.lockRecoveryCandidatePrefix, + lockPath: paths.lockPath, + recoveryPath: paths.lockRecoveryPath, + }); + lock = await publication.acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 250, + }); + await publication.releaseBuildLock(lock); + lock = null; + }, + ); + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("lock inspection fails closed when owner metadata cannot be read", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-owner-read-failure-")); + const output = join(parent, "dist"); + const failure = filesystemError("EIO", "injected owner metadata read failure"); + + try { + await withPublicationFsFault( + { + readFile(real, path, ...args) { + if (path === this.ownerPath) { + throw failure; + } + return real.readFile(path, ...args); + }, + }, + async (publication) => { + const paths = publication.buildStatePaths(output); + createDeadLock(paths, "unreadable-dead-lock-owner"); + publicationFsFault.current.ownerPath = paths.lockOwnerPath; + await assert.rejects( + publication.acquireBuildLock(output), + (error) => error === failure, + ); + assert.equal(existsSync(paths.lockPath), true); + }, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("a caught second publication rename failure restores previous output", async () => { + const { buildStatePaths, publishBuild } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-publish-failure-")); + const output = join(parent, "dist"); + const stage = mkdtempSync(join(parent, ".dist.stage-")); + const failure = new Error("injected stage publication failure"); + mkdirSync(output); + writeFileSync(join(output, "prior.txt"), "prior", "utf8"); + writeFileSync(join(stage, "next.txt"), "next", "utf8"); + try { + await assert.rejects( + publishBuild(stage, output, { + async renamePath(from, to) { + if (from === stage && to === output) { + throw failure; + } + renameSync(from, to); + }, + }), + (error) => error === failure, + ); + + assert.equal(readFileSync(join(output, "prior.txt"), "utf8"), "prior"); + assert.equal(existsSync(stage), true); + assert.equal(existsSync(buildStatePaths(output).backupPath), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("publication reports both the publish failure and failed rollback", async () => { + const { buildStatePaths, publishBuild } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-publish-rollback-failure-")); + const output = join(parent, "dist"); + const stage = mkdtempSync(join(parent, ".dist.stage-")); + const publishFailure = new Error("injected publication failure"); + const rollbackFailure = new Error("injected rollback failure"); + mkdirSync(output); + writeFileSync(join(output, "prior.txt"), "prior", "utf8"); + writeFileSync(join(stage, "next.txt"), "next", "utf8"); + let renameCount = 0; + + try { + await assert.rejects( + publishBuild(stage, output, { + async renamePath(from, to) { + renameCount += 1; + if (renameCount === 1) { + renameSync(from, to); + return; + } + throw renameCount === 2 ? publishFailure : rollbackFailure; + }, + }), + (error) => { + assert.equal(error instanceof AggregateError, true); + assert.deepEqual(error.errors, [publishFailure, rollbackFailure]); + assert.match(error.message, /previous output remains/); + return true; + }, + ); + + const { backupPath } = buildStatePaths(output); + assert.equal(existsSync(output), false); + assert.equal(readFileSync(join(backupPath, "prior.txt"), "utf8"), "prior"); + assert.equal(readFileSync(join(stage, "next.txt"), "utf8"), "next"); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("publication leaves output and stage untouched when backup creation fails", async () => { + const { buildStatePaths, publishBuild } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-backup-rename-failure-")); + const output = join(parent, "dist"); + const stage = mkdtempSync(join(parent, ".dist.stage-")); + const failure = Object.assign(new Error("injected backup rename failure"), { + code: "EACCES", + }); + mkdirSync(output); + writeFileSync(join(output, "prior.txt"), "prior", "utf8"); + writeFileSync(join(stage, "next.txt"), "next", "utf8"); + + try { + await assert.rejects( + publishBuild(stage, output, { + async renamePath() { + throw failure; + }, + }), + (error) => error === failure, + ); + + assert.equal(readFileSync(join(output, "prior.txt"), "utf8"), "prior"); + assert.equal(readFileSync(join(stage, "next.txt"), "utf8"), "next"); + assert.equal(existsSync(buildStatePaths(output).backupPath), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("recovery restores a backup when publication stopped in the rename gap", async () => { + const { + acquireBuildLock, + buildStatePaths, + reconcileBuildState, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-direct-backup-recovery-")); + const output = join(parent, "dist"); + const { backupPath } = buildStatePaths(output); + mkdirSync(backupPath); + writeFileSync(join(backupPath, "prior.txt"), "prior", "utf8"); + let lock; + + try { + lock = await acquireBuildLock(output); + await reconcileBuildState(output, lock); + + assert.equal(readFileSync(join(output, "prior.txt"), "utf8"), "prior"); + assert.equal(existsSync(backupPath), false); + } finally { + if (lock) { + await releaseBuildLock(lock); + } + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("one destination lock cannot authorize recovery for another destination", async () => { + const { + acquireBuildLock, + reconcileBuildState, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-cross-destination-lock-")); + const firstOutput = join(parent, "first-dist"); + const secondOutput = join(parent, "second-dist"); + const lock = await acquireBuildLock(firstOutput); + + try { + await assert.rejects( + reconcileBuildState(secondOutput, lock), + /Build lock is not owned by this process/, + ); + assert.equal(existsSync(secondOutput), false); + } finally { + await releaseBuildLock(lock); + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("publication accepts only generated stage siblings under the destination parent", async () => { + const { publishBuild } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-stage-boundary-")); + const externalParent = mkdtempSync(join(tmpdir(), "salvo-external-stage-boundary-")); + const output = join(parent, "dist"); + const ordinarySibling = join(parent, "ordinary-stage"); + const externalStage = join(externalParent, ".dist.stage-external"); + mkdirSync(ordinarySibling); + mkdirSync(externalStage); + + try { + await assert.rejects( + publishBuild(externalStage, output), + /must remain under the canonical build destination parent/, + ); + await assert.rejects( + publishBuild(ordinarySibling, output), + /must be a generated sibling of the destination/, + ); + assert.equal(existsSync(ordinarySibling), true); + assert.equal(existsSync(externalStage), true); + } finally { + rmSync(parent, { recursive: true, force: true }); + rmSync(externalParent, { recursive: true, force: true }); + } +}); + +test("lock acquisition validates lifecycle hooks before creating state", async () => { + const { acquireBuildLock } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-lock-hook-validation-")); + const output = join(parent, "dist"); + const cases = [ + ["onCandidateReady", /onCandidateReady must be a function/], + ["onRecoveryCandidateReady", /onRecoveryCandidateReady must be a function/], + ["onRecoveryClaimPublished", /onRecoveryClaimPublished must be a function/], + ]; + + try { + for (const [name, message] of cases) { + await assert.rejects( + acquireBuildLock(output, { [name]: true }), + (error) => error instanceof TypeError && message.test(error.message), + ); + } + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("lock acquisition fails cleanly when the destination parent is missing", async () => { + const { acquireBuildLock } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-missing-lock-parent-")); + const missingParent = join(parent, "missing"); + const output = join(missingParent, "dist"); + + try { + await assert.rejects( + acquireBuildLock(output), + (error) => error?.code === "ENOENT", + ); + assert.equal(existsSync(missingParent), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("lock acquisition rejects a candidate removed before publication", async () => { + const { acquireBuildLock } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-missing-lock-candidate-")); + const output = join(parent, "dist"); + + try { + await assert.rejects( + acquireBuildLock(output, { + onCandidateReady({ candidatePath }) { + rmSync(candidatePath, { recursive: true, force: true }); + }, + }), + (error) => error?.code === "ENOENT", + ); + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("lock acquisition refuses to clean a replaced candidate directory", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-replaced-lock-candidate-")); + const output = join(parent, "dist"); + const { lockPath } = buildStatePaths(output); + let candidatePath; + let retiredPath; + + try { + await assert.rejects( + acquireBuildLock(output, { + onCandidateReady(candidate) { + candidatePath = candidate.candidatePath; + retiredPath = `${candidatePath}.retired`; + renameSync(candidatePath, retiredPath); + mkdirSync(candidatePath); + writeFileSync(candidate.ownerPath, JSON.stringify(candidate.owner), "utf8"); + mkdirSync(lockPath); + }, + }), + /Build lock candidate ownership changed before cleanup/, + ); + assert.equal(existsSync(candidatePath), true); + assert.equal(existsSync(retiredPath), true); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("lock acquisition refuses to clean a candidate with replaced owner metadata", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-replaced-candidate-owner-")); + const output = join(parent, "dist"); + const { lockPath } = buildStatePaths(output); + let candidatePath; + + try { + await assert.rejects( + acquireBuildLock(output, { + onCandidateReady(candidate) { + candidatePath = candidate.candidatePath; + writeFileSync( + candidate.ownerPath, + JSON.stringify({ ...candidate.owner, token: "replacement-owner" }), + "utf8", + ); + mkdirSync(lockPath); + }, + }), + /Build lock candidate ownership changed before cleanup/, + ); + assert.equal(existsSync(candidatePath), true); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("stale lock recovery preserves an owner installed after the claim", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-recovery-owner-race-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath, lockRecoveryPath } = buildStatePaths(output); + const deadOwner = { + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-owner-before-recovery-claim", + }; + const replacementOwner = { + pid: process.pid, + timestamp: Date.now(), + token: "live-owner-after-recovery-claim", + }; + mkdirSync(lockPath); + writeFileSync(lockOwnerPath, JSON.stringify(deadOwner), "utf8"); + let publishedClaims = 0; + + try { + await assert.rejects( + acquireBuildLock(output, { + onRecoveryClaimPublished() { + publishedClaims += 1; + writeFileSync(lockOwnerPath, JSON.stringify(replacementOwner), "utf8"); + }, + retryMs: 5, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.equal(publishedClaims, 1); + assert.deepEqual(JSON.parse(readFileSync(lockOwnerPath, "utf8")), replacementOwner); + assert.equal(existsSync(lockRecoveryPath), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("stale lock recovery yields to a competing live recovery claim", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-competing-recovery-claim-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath, lockRecoveryPath } = buildStatePaths(output); + const competingOwner = { + pid: process.pid, + timestamp: Date.now(), + token: "competing-live-recovery-owner", + }; + mkdirSync(lockPath); + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-owner-before-competing-claim", + }), + "utf8", + ); + let candidateNotifications = 0; + + try { + await assert.rejects( + acquireBuildLock(output, { + onRecoveryCandidateReady() { + candidateNotifications += 1; + mkdirSync(lockRecoveryPath); + writeFileSync( + join(lockRecoveryPath, "owner.json"), + JSON.stringify(competingOwner), + "utf8", + ); + }, + retryMs: 5, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.equal(candidateNotifications, 1); + assert.deepEqual( + JSON.parse(readFileSync(join(lockRecoveryPath, "owner.json"), "utf8")), + competingOwner, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("stale lock recovery does not remove a replacement lock inode", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-replaced-lock-after-claim-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath, lockRecoveryPath } = buildStatePaths(output); + const replacementOwner = { + pid: process.pid, + timestamp: Date.now(), + token: "replacement-lock-inode-owner", + }; + mkdirSync(lockPath); + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-owner-before-lock-replacement", + }), + "utf8", + ); + + try { + await assert.rejects( + acquireBuildLock(output, { + onRecoveryClaimPublished() { + const detachedClaim = join(parent, "detached-recovery-claim"); + renameSync(lockRecoveryPath, detachedClaim); + rmSync(lockPath, { recursive: true, force: true }); + mkdirSync(lockPath); + writeFileSync(lockOwnerPath, JSON.stringify(replacementOwner), "utf8"); + renameSync(detachedClaim, lockRecoveryPath); + }, + retryMs: 5, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.deepEqual(JSON.parse(readFileSync(lockOwnerPath, "utf8")), replacementOwner); + assert.equal(existsSync(lockRecoveryPath), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("ownerless stale lock recovery preserves an owner installed after the claim", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-ownerless-recovery-race-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath, lockRecoveryPath } = buildStatePaths(output); + const replacementOwner = { + pid: process.pid, + timestamp: Date.now(), + token: "owner-installed-after-ownerless-claim", + }; + mkdirSync(lockPath); + const staleTime = new Date(Date.now() - 120_000); + utimesSync(lockPath, staleTime, staleTime); + + try { + await assert.rejects( + acquireBuildLock(output, { + onRecoveryClaimPublished() { + writeFileSync(lockOwnerPath, JSON.stringify(replacementOwner), "utf8"); + }, + retryMs: 5, + staleMs: 60_000, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.deepEqual(JSON.parse(readFileSync(lockOwnerPath, "utf8")), replacementOwner); + assert.equal(existsSync(lockRecoveryPath), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("stale lock recovery refuses to release a claim with replaced metadata", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-replaced-recovery-owner-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath, lockRecoveryPath } = buildStatePaths(output); + mkdirSync(lockPath); + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-owner-before-claim-tampering", + }), + "utf8", + ); + + try { + await assert.rejects( + acquireBuildLock(output, { + onRecoveryClaimPublished({ owner, ownerPath }) { + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: process.pid, + timestamp: Date.now(), + token: "replacement-lock-owner", + }), + "utf8", + ); + writeFileSync( + ownerPath, + JSON.stringify({ ...owner, token: "replacement-claim-owner" }), + "utf8", + ); + }, + retryMs: 5, + timeoutMs: 75, + }), + /Build recovery claim ownership changed before release/, + ); + assert.equal(existsSync(lockRecoveryPath), true); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("stale lock recovery tolerates malformed dead-owner metadata", async () => { + const { + acquireBuildLock, + buildStatePaths, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-malformed-lock-owner-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath } = buildStatePaths(output); + mkdirSync(lockPath); + writeFileSync(lockOwnerPath, "{not-json", "utf8"); + const staleTime = new Date(Date.now() - 120_000); + utimesSync(lockPath, staleTime, staleTime); + let lock; + + try { + lock = await acquireBuildLock(output, { + retryMs: 5, + staleMs: 60_000, + timeoutMs: 250, + }); + assert.notEqual(lock.owner.token, undefined); + await releaseBuildLock(lock); + lock = null; + assert.equal(existsSync(lockPath), false); + } finally { + if (lock) { + await releaseBuildLock(lock).catch(() => {}); + } + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("lock acquisition rejects non-file owner metadata without deleting it", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-lock-owner-directory-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath } = buildStatePaths(output); + mkdirSync(lockPath); + mkdirSync(lockOwnerPath); + + try { + await assert.rejects( + acquireBuildLock(output), + /Build lock owner path must be a regular file/, + ); + assert.equal(existsSync(lockOwnerPath), true); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("a dead lock owner is recovered and replaced with owner metadata", async () => { + const { + acquireBuildLock, + buildStatePaths, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-stale-lock-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath } = buildStatePaths(output); + mkdirSync(lockPath); + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-owner", + }), + "utf8", + ); + try { + const lock = await acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 250, + }); + const owner = JSON.parse(readFileSync(lockOwnerPath, "utf8")); + assert.equal(owner.pid, process.pid); + assert.equal(typeof owner.timestamp, "number"); + assert.notEqual(owner.token, "dead-owner"); + await releaseBuildLock(lock); + assert.equal(existsSync(lockPath), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("a paused lock candidate cannot overlap another published lock", async () => { + const { + acquireBuildLock, + buildStatePaths, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-candidate-lock-")); + const output = join(parent, "dist"); + const { lockPath } = buildStatePaths(output); + let resumeCandidate; + const candidateGate = new Promise((resolveGate) => { + resumeCandidate = resolveGate; + }); + let reportCandidateReady; + const candidateReady = new Promise((resolveReady) => { + reportCandidateReady = resolveReady; + }); + let firstLock = null; + let secondLock = null; + const firstLockPromise = acquireBuildLock(output, { + async onCandidateReady({ candidatePath, owner, ownerPath }) { + assert.equal(existsSync(lockPath), false); + assert.equal(candidatePath.startsWith(`${lockPath}.candidate-`), true); + assert.deepEqual(JSON.parse(readFileSync(ownerPath, "utf8")), owner); + reportCandidateReady(); + await candidateGate; + }, + retryMs: 5, + timeoutMs: 500, + }).then((lock) => { + firstLock = lock; + return lock; + }); + + try { + await Promise.race([ + candidateReady, + firstLockPromise.then(() => { + throw new Error("first lock published before its candidate resumed"); + }), + delay(250).then(() => { + throw new Error("lock candidate was not reported ready"); + }), + ]); + + secondLock = await acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 500, + }); + resumeCandidate(); + await delay(40); + assert.equal(firstLock, null, "both lock owners became active"); + + await releaseBuildLock(secondLock); + secondLock = null; + firstLock = await firstLockPromise; + await releaseBuildLock(firstLock); + firstLock = null; + assertNoBuildDebris(output); + } finally { + resumeCandidate(); + if (secondLock) { + await releaseBuildLock(secondLock).catch(() => {}); + } + if (!firstLock) { + firstLock = await Promise.race([ + firstLockPromise.catch(() => null), + delay(300).then(() => null), + ]); + } + if (firstLock) { + await releaseBuildLock(firstLock).catch(() => {}); + } + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("a paused recovery candidate cannot claim a replacement lock", async () => { + const { + acquireBuildLock, + buildStatePaths, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-recovery-candidate-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath, lockRecoveryPath } = buildStatePaths(output); + mkdirSync(lockPath); + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-recovery-owner", + }), + "utf8", + ); + let resumeCandidate; + const candidateGate = new Promise((resolveGate) => { + resumeCandidate = resolveGate; + }); + let reportCandidateReady; + const candidateReady = new Promise((resolveReady) => { + reportCandidateReady = resolveReady; + }); + let firstLock = null; + let secondLock = null; + const firstLockPromise = acquireBuildLock(output, { + async onRecoveryCandidateReady({ candidatePath, owner, ownerPath }) { + assert.equal(existsSync(lockRecoveryPath), false); + assert.equal( + candidatePath.startsWith(`${lockPath}.recovery-candidate-`), + true, + ); + assert.deepEqual(JSON.parse(readFileSync(ownerPath, "utf8")), owner); + reportCandidateReady(); + await candidateGate; + }, + retryMs: 5, + timeoutMs: 500, + }).then((lock) => { + firstLock = lock; + return lock; + }); + + try { + await Promise.race([ + candidateReady, + firstLockPromise.then(() => { + throw new Error("stale lock recovered before its candidate resumed"); + }), + delay(250).then(() => { + throw new Error("recovery candidate was not reported ready"); + }), + ]); + + secondLock = await acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 500, + }); + resumeCandidate(); + await delay(40); + assert.equal(firstLock, null, "both recovery owners became active"); + + await releaseBuildLock(secondLock); + secondLock = null; + firstLock = await firstLockPromise; + await releaseBuildLock(firstLock); + firstLock = null; + assertNoBuildDebris(output); + } finally { + resumeCandidate(); + if (secondLock) { + await releaseBuildLock(secondLock).catch(() => {}); + } + if (!firstLock) { + firstLock = await Promise.race([ + firstLockPromise.catch(() => null), + delay(300).then(() => null), + ]); + } + if (firstLock) { + await releaseBuildLock(firstLock).catch(() => {}); + } + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("a build recovers a claim abandoned immediately after publication", () => { + const isolatedRoot = makeIsolatedProject(); + const output = join(isolatedRoot, "dist"); + const lockPath = join(isolatedRoot, ".dist.lock"); + const lockOwnerPath = join(lockPath, "owner.json"); + const recoveryPath = join(lockPath, ".recovery"); + const publicationModule = pathToFileURL( + join(isolatedRoot, "scripts/build-publication.mjs"), + ).href; + mkdirSync(lockPath); + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-lock-before-claim-crash", + }), + "utf8", + ); + + try { + const interrupted = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` + import { acquireBuildLock } from ${JSON.stringify(publicationModule)}; + + await acquireBuildLock(${JSON.stringify(output)}, { + onRecoveryClaimPublished() { + process.exit(73); + }, + retryMs: 5, + timeoutMs: 500, + }); + process.exit(74); + `, + ], + { encoding: "utf8" }, + ); + assert.equal(interrupted.status, 73, interrupted.stderr); + assert.equal(existsSync(lockPath), true); + assert.equal(existsSync(recoveryPath), true); + const abandonedOwner = JSON.parse( + readFileSync(join(recoveryPath, "owner.json"), "utf8"), + ); + assert.equal(abandonedOwner.pid, interrupted.pid); + + const recovered = build({ + buildId: "after-abandoned-claim", + cwd: isolatedRoot, + output, + }); + assertBuildSucceeded(recovered.result); + assert.match( + readFileSync(join(output, "index.html"), "utf8"), + /buildId: "after-abandoned-claim"/, + ); + assertNoBuildDebris(output); + } finally { + rmSync(isolatedRoot, { recursive: true, force: true }); + } +}); + +test("a live recovery claim is never stolen even after its stale threshold", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-live-recovery-claim-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath, lockRecoveryPath } = buildStatePaths(output); + const liveClaimOwner = { + pid: process.pid, + timestamp: Date.now() - 60 * 60 * 1000, + token: "live-recovery-claim", + }; + mkdirSync(lockPath); + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-lock-with-live-claim", + }), + "utf8", + ); + mkdirSync(lockRecoveryPath); + writeFileSync( + join(lockRecoveryPath, "owner.json"), + JSON.stringify(liveClaimOwner), + "utf8", + ); + + try { + await assert.rejects( + acquireBuildLock(output, { + retryMs: 5, + staleMs: 1, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.deepEqual( + JSON.parse(readFileSync(join(lockRecoveryPath, "owner.json"), "utf8")), + liveClaimOwner, + ); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("an ownerless recovery claim is recovered only after the stale threshold", async () => { + const { + acquireBuildLock, + buildStatePaths, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-ownerless-recovery-claim-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath, lockRecoveryPath } = buildStatePaths(output); + mkdirSync(lockPath); + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-lock-with-ownerless-claim", + }), + "utf8", + ); + mkdirSync(lockRecoveryPath); + + try { + await assert.rejects( + acquireBuildLock(output, { + retryMs: 5, + staleMs: 60_000, + timeoutMs: 50, + }), + /Timed out waiting for build lock/, + ); + assert.equal(existsSync(lockRecoveryPath), true); + + const staleTime = new Date(Date.now() - 120_000); + utimesSync(lockRecoveryPath, staleTime, staleTime); + const lock = await acquireBuildLock(output, { + retryMs: 5, + staleMs: 60_000, + timeoutMs: 250, + }); + await releaseBuildLock(lock); + assert.equal(existsSync(lockPath), false); + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("a symlinked recovery claim is rejected without touching its target", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-symlink-recovery-claim-")); + const external = mkdtempSync(join(tmpdir(), "salvo-external-recovery-claim-")); + const output = join(parent, "dist"); + const sentinel = join(external, "sentinel.txt"); + const { lockOwnerPath, lockPath, lockRecoveryPath } = buildStatePaths(output); + writeFileSync(sentinel, "external sentinel", "utf8"); + mkdirSync(lockPath); + writeFileSync( + lockOwnerPath, + JSON.stringify({ + pid: 2_147_483_647, + timestamp: Date.now(), + token: "dead-lock-with-symlinked-claim", + }), + "utf8", + ); + symlinkSync(external, lockRecoveryPath, "dir"); + + try { + await assert.rejects( + acquireBuildLock(output, { + retryMs: 5, + timeoutMs: 75, + }), + /Build lock recovery path must be a real directory/, + ); + assert.equal(readFileSync(sentinel, "utf8"), "external sentinel"); + assert.deepEqual(readdirSync(external), ["sentinel.txt"]); + } finally { + rmSync(parent, { recursive: true, force: true }); + rmSync(external, { recursive: true, force: true }); + } +}); + +test("an old owner cannot release a replacement lock", async () => { + const { + acquireBuildLock, + buildStatePaths, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-replaced-lock-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath } = buildStatePaths(output); + const oldLock = await acquireBuildLock(output); + const retiredPath = `${lockPath}.retired-${oldLock.owner.token}`; + renameSync(lockPath, retiredPath); + let replacementLock = null; + try { + replacementLock = await acquireBuildLock(output); + await assert.rejects( + releaseBuildLock(oldLock), + /Build lock ownership changed before release/, + ); + const currentOwner = JSON.parse(readFileSync(lockOwnerPath, "utf8")); + assert.equal(currentOwner.token, replacementLock.owner.token); + } finally { + if (replacementLock) { + await releaseBuildLock(replacementLock); + } + rmSync(retiredPath, { recursive: true, force: true }); + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("an old lock owned by a live process is never stolen", async () => { + const { acquireBuildLock, buildStatePaths } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-live-lock-")); + const output = join(parent, "dist"); + const { lockOwnerPath, lockPath } = buildStatePaths(output); + const liveOwner = { + pid: process.pid, + timestamp: Date.now() - 60 * 60 * 1000, + token: "live-owner", + }; + mkdirSync(lockPath); + writeFileSync(lockOwnerPath, JSON.stringify(liveOwner), "utf8"); + try { + await assert.rejects( + acquireBuildLock(output, { + retryMs: 5, + staleMs: 1, + timeoutMs: 75, + }), + /Timed out waiting for build lock/, + ); + assert.deepEqual(JSON.parse(readFileSync(lockOwnerPath, "utf8")), liveOwner); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("an ownerless lock is recovered only after the stale threshold", async () => { + const { + acquireBuildLock, + buildStatePaths, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-ownerless-lock-")); + const output = join(parent, "dist"); + const { lockPath } = buildStatePaths(output); + mkdirSync(lockPath); + try { + await assert.rejects( + acquireBuildLock(output, { + retryMs: 5, + staleMs: 60_000, + timeoutMs: 50, + }), + /Timed out waiting for build lock/, + ); + assert.equal(existsSync(lockPath), true); + + const staleTime = new Date(Date.now() - 120_000); + utimesSync(lockPath, staleTime, staleTime); + const lock = await acquireBuildLock(output, { + retryMs: 5, + staleMs: 60_000, + timeoutMs: 250, + }); + await releaseBuildLock(lock); + assert.equal(existsSync(lockPath), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("recovery keeps a completed destination and cleans debris only under lock", async () => { + const { + acquireBuildLock, + buildStatePaths, + reconcileBuildState, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-completed-publish-")); + const output = join(parent, "dist"); + const { backupPath, lockPath } = buildStatePaths(output); + const stage = join(parent, ".dist.stage-abandoned"); + mkdirSync(output); + mkdirSync(backupPath); + mkdirSync(stage); + writeFileSync(join(output, "current.txt"), "current", "utf8"); + writeFileSync(join(backupPath, "prior.txt"), "prior", "utf8"); + try { + await assert.rejects( + reconcileBuildState(output, { + owner: { pid: process.pid, timestamp: Date.now(), token: "not-owner" }, + path: lockPath, + }), + /not owned by this process/, + ); + assert.equal(existsSync(backupPath), true); + assert.equal(existsSync(stage), true); + + const lock = await acquireBuildLock(output); + try { + await reconcileBuildState(output, lock); + } finally { + await releaseBuildLock(lock); + } + assert.equal(readFileSync(join(output, "current.txt"), "utf8"), "current"); + assert.equal(existsSync(backupPath), false); + assert.equal(existsSync(stage), false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("recovery removes an abandoned real recovery quarantine under lock", async () => { + const { + acquireBuildLock, + buildStatePaths, + reconcileBuildState, + releaseBuildLock, + } = await loadPublicationModule(); + const parent = mkdtempSync(join(tmpdir(), "salvo-abandoned-quarantine-")); + const output = join(parent, "dist"); + const { lockRecoveryQuarantinePrefix } = buildStatePaths(output); + const quarantine = `${lockRecoveryQuarantinePrefix}abandoned`; + mkdirSync(quarantine); + writeFileSync(join(quarantine, "owner.json"), "abandoned", "utf8"); + + try { + const lock = await acquireBuildLock(output); + try { + await reconcileBuildState(output, lock); + } finally { + await releaseBuildLock(lock); + } + assert.equal(existsSync(quarantine), false); + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("concurrent builds to one destination serialize and publish one complete result", async () => { + const parent = mkdtempSync(join(tmpdir(), "salvo-concurrent-build-")); + const output = join(parent, "dist"); + try { + const builds = await Promise.all([ + buildAsync({ buildId: "concurrent-a", output }), + buildAsync({ buildId: "concurrent-b", output }), + ]); + for (const { result } of builds) { + assertBuildSucceeded(result); + } + + const web = readFileSync(join(output, "index.html"), "utf8"); + const telegram = readFileSync(join(output, "telegram/index.html"), "utf8"); + const webBuildId = web.match(/buildId: "([^"]+)"/)?.[1]; + const telegramBuildId = telegram.match(/buildId: "([^"]+)"/)?.[1]; + assert.equal(telegramBuildId, webBuildId); + assert.equal(["concurrent-a", "concurrent-b"].includes(webBuildId), true); + const { app, map, stylesheet } = readArtifacts(output); + for (const artifact of [app, map, stylesheet]) { + assert.equal(existsSync(join(output, artifact)), true, artifact); + } + assertNoBuildDebris(output); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("build rejects missing or duplicate exact shell replacement markers", () => { + const cases = [ + { + file: "src/index.html", + marker: '', + replacement: '', + }, + { + file: "src/telegram/index.html", + marker: '', + replacement: + '\n', + }, + { + file: "src/index.html", + marker: 'buildId: "dev"', + replacement: 'buildId: "dev"\n buildId: "dev"', + }, + ]; + + for (const fixture of cases) { + const isolatedRoot = makeIsolatedProject(); + const path = join(isolatedRoot, fixture.file); + try { + const source = readFileSync(path, "utf8"); + assert.equal( + count(source, fixture.marker), + 1, + `${basename(path)} fixture marker`, + ); + writeFileSync(path, source.replace(fixture.marker, fixture.replacement)); + + const { result } = build({ + cwd: isolatedRoot, + output: join(isolatedRoot, "dist"), + }); + assert.notEqual( + result.status, + 0, + `${fixture.file} malformed template was accepted`, + ); + assert.match(result.stderr, /exactly one occurrence/i); + } finally { + rmSync(isolatedRoot, { recursive: true, force: true }); + } + } +}); diff --git a/tests/telegram-launch.test.mjs b/tests/telegram-launch.test.mjs new file mode 100644 index 0000000..26d83cf --- /dev/null +++ b/tests/telegram-launch.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseTelegramStartParam, + telegramMainMiniAppUrl, + telegramReplayUrl, + telegramRoomInviteUrl, +} from "../src/telegram-launch.js"; + +test("parseTelegramStartParam accepts only exact room and replay launch patterns", () => { + assert.deepEqual(parseTelegramStartParam("room_ABCD"), { + type: "room", + roomCode: "ABCD", + }); + assert.deepEqual(parseTelegramStartParam("room_A1B2C3D4E5F6"), { + type: "room", + roomCode: "A1B2C3D4E5F6", + }); + assert.deepEqual(parseTelegramStartParam("replay_replay-123"), { + type: "replay", + replayId: "replay-123", + }); + assert.deepEqual(parseTelegramStartParam(`replay_${"a".repeat(128)}`), { + type: "replay", + replayId: "a".repeat(128), + }); +}); + +test("parseTelegramStartParam returns null for unknown, malformed, and hostile values", () => { + const invalidValues = [ + undefined, + null, + false, + 42, + {}, + [], + "", + "menu", + "room_abcd", + "room_ABC", + "room_ABCDEFGHIJKLM", + "room_ABCD?x", + "room_ABCD&startapp=replay_x", + "room_AB/CD", + "room_АБВГ", + " room_ABCD", + "room_ABCD ", + "replay_", + `replay_${"a".repeat(129)}`, + "replay_a_b", + "replay_a/b", + "replay_a?b", + "replay_a#b", + "replay_a%b", + "replay_данные", + ]; + + for (const value of invalidValues) { + assert.equal(parseTelegramStartParam(value), null, String(value)); + } +}); + +test("canonical Telegram room and replay links use encoded startapp parameters", () => { + assert.equal( + telegramMainMiniAppUrl("agents_salvo_bot"), + "https://t.me/agents_salvo_bot?startapp", + ); + assert.equal( + telegramRoomInviteUrl("agents_salvo_bot", "ABCD"), + "https://t.me/agents_salvo_bot?startapp=room_ABCD", + ); + assert.equal( + telegramReplayUrl("Agents_Salvo_Bot", "replay-123"), + "https://t.me/Agents_Salvo_Bot?startapp=replay_replay-123", + ); +}); + +test("launch links reject invalid bot usernames including URL and credential syntax", () => { + const invalidUsernames = [ + undefined, + null, + "", + "abcd", + "1agents", + "_agents", + "agents-salvo", + "agents.salvo", + "agents/salvo", + "agents?startapp=room_EVIL", + "agents#fragment", + "user@evil.test", + "https://t.me/agents_salvo_bot", + "аgents_salvo_bot", + `a${"b".repeat(32)}`, + ]; + + for (const botUsername of invalidUsernames) { + assert.throws(() => telegramMainMiniAppUrl(botUsername), { name: "TypeError" }); + assert.throws(() => telegramRoomInviteUrl(botUsername, "ABCD"), { name: "TypeError" }); + assert.throws(() => telegramReplayUrl(botUsername, "replay-123"), { name: "TypeError" }); + } +}); + +test("launch links reject malformed room codes and replay identifiers", () => { + for (const roomCode of [ + undefined, + null, + "", + "ABC", + "ABCDEFGHIJKLM", + "abcd", + "AB_D", + "AB/CD", + "AB?D", + "АБВГ", + ]) { + assert.throws(() => telegramRoomInviteUrl("agents_salvo_bot", roomCode), { + name: "TypeError", + }, String(roomCode)); + } + + for (const replayId of [ + undefined, + null, + "", + "a".repeat(129), + "replay_id", + "replay/id", + "replay?id", + "replay&id", + "replay#id", + "replay%20id", + "повтор", + ]) { + assert.throws(() => telegramReplayUrl("agents_salvo_bot", replayId), { + name: "TypeError", + }, String(replayId)); + } +}); diff --git a/tests/telegram-layout-browser.test.mjs b/tests/telegram-layout-browser.test.mjs new file mode 100644 index 0000000..43d4c74 --- /dev/null +++ b/tests/telegram-layout-browser.test.mjs @@ -0,0 +1,520 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; + +const chromeCandidates = [ + process.env.CHROME_BIN, + "google-chrome", + "chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", +].filter(Boolean); + +function chromeBinary() { + for (const candidate of chromeCandidates) { + if (candidate.includes("/") ? existsSync(candidate) : canRun(candidate)) return candidate; + } + assert.fail(`Chrome is required for layout verification; checked ${chromeCandidates.join(", ")}`); +} + +function canRun(candidate) { + try { + execFileSync(candidate, ["--version"], { stdio: "ignore", timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +class FakeSocket { + constructor() { + this.listeners = new Map(); + } + + addEventListener(type, listener) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + emit(type, event = {}) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + send() {} + + close() {} +} + +test("Chrome harness bounds stalled operations", async () => { + await assert.rejects( + () => withTimeout(new Promise(() => {}), "stalled operation", 5), + /stalled operation timed out after 5ms/, + ); +}); + +test("Chrome harness retries target discovery and rejects all pending CDP work on socket faults", async () => { + let targetRequests = 0; + const socket = new FakeSocket(); + const sessionPromise = openCdp(9222, { + fetcher: async () => { + targetRequests += 1; + if (targetRequests === 1) throw new Error("connection refused"); + return { + ok: true, + json: async () => [{ type: "page", webSocketDebuggerUrl: "ws://fake" }], + }; + }, + webSocketFactory: () => { + queueMicrotask(() => socket.emit("open")); + return socket; + }, + retryDelay: () => Promise.resolve(), + timeoutMs: 50, + }); + const cdp = await sessionPromise; + assert.equal(targetRequests, 2); + + const call = cdp.call("Runtime.evaluate"); + const event = cdp.waitFor("Page.loadEventFired"); + socket.emit("close"); + await assert.rejects(call, /Chrome DevTools socket closed/); + await assert.rejects(event, /Chrome DevTools socket closed/); + + const errorSocket = new FakeSocket(); + const errorCdp = createCdpSession(errorSocket, { timeoutMs: 50 }); + const errorCall = errorCdp.call("Runtime.evaluate"); + const errorEvent = errorCdp.waitFor("Page.loadEventFired"); + errorSocket.emit("error"); + await assert.rejects(errorCall, /Chrome DevTools socket error/); + await assert.rejects(errorEvent, /Chrome DevTools socket error/); +}); + +test("Chrome harness waits for graceful exit before using SIGKILL fallback", async () => { + const listeners = new Map(); + const signals = []; + const child = { + exitCode: null, + signalCode: null, + once(type, listener) { + listeners.set(type, listener); + }, + kill(signal) { + signals.push(signal); + if (signal === "SIGKILL") { + queueMicrotask(() => { + child.signalCode = "SIGKILL"; + listeners.get("close")?.(); + }); + } + return true; + }, + }; + + await terminateChrome(child, { gracefulTimeoutMs: 5, killTimeoutMs: 50 }); + assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]); +}); + +test("Chrome harness observes navigation and load failures before cleanup", async () => { + const unhandled = []; + const onUnhandledRejection = (error) => unhandled.push(error); + process.on("unhandledRejection", onUnhandledRejection); + let cleaned = false; + try { + await assert.rejects( + async () => { + try { + await navigateAndWait({ + waitFor: () => Promise.reject(new Error("load event failed")), + call: () => Promise.reject(new Error("navigation failed")), + }, "file:///layout.html"); + } finally { + cleaned = true; + } + }, + /navigation failed/, + ); + await delay(0); + assert.equal(cleaned, true); + assert.deepEqual(unhandled, []); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } +}); + +function board(size, id) { + const cells = "".repeat(size * size); + const labels = "A".repeat(size); + return ` +
+
+
+ +
${labels}
+
${labels}
+
${cells}
+
+
+
`; +} + +function layoutHtml() { + const stylesheet = pathToFileURL(join(process.cwd(), "src/styles.css")).href; + return ` + + + +
+ ${board(10, "setup-10")} +
${board(16, "inside-replay-16")}
+
+ + + + `; +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function withTimeout(operation, label, timeoutMs) { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + Promise.resolve(operation).then( + (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }, + (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +function observeChrome(child) { + let failure = null; + let stderr = ""; + const listeners = new Set(); + const fail = (error) => { + if (failure) return; + failure = error; + listeners.forEach((listener) => listener(error)); + listeners.clear(); + }; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk) => { + stderr = `${stderr}${chunk}`.slice(-8_192); + }); + child.once("error", (error) => fail(new Error(`Chrome process error: ${error.message}`))); + child.once("close", (code, signal) => { + fail(new Error(`Chrome exited before layout measurement (code ${code}, signal ${signal ?? "none"})`)); + }); + return { + assertRunning() { + if (failure) throw failure; + }, + onFailure(listener) { + if (failure) listener(failure); + else listeners.add(listener); + return () => listeners.delete(listener); + }, + diagnose(error) { + const details = stderr.trim(); + return details ? new Error(`${error.message}\nChrome stderr:\n${details}`) : error; + }, + }; +} + +async function waitForDevToolsPort(profile, chrome, { timeoutMs = 20_000 } = {}) { + const activePortFile = join(profile, "DevToolsActivePort"); + return withTimeout((async () => { + while (true) { + chrome.assertRunning(); + if (existsSync(activePortFile)) { + const [port] = readFileSync(activePortFile, "utf8").trim().split("\n"); + if (/^\d+$/.test(port)) return Number(port); + } + await delay(50); + } + })(), "Chrome startup", timeoutMs); +} + +function createCdpSession(socket, { timeoutMs = 10_000 } = {}) { + let closeError = null; + const failPending = (error) => { + if (closeError) return; + closeError = error; + for (const { reject, timer } of requests.values()) { + clearTimeout(timer); + reject(error); + } + requests.clear(); + for (const listeners of events.values()) { + for (const { reject, timer } of listeners) { + clearTimeout(timer); + reject(error); + } + } + events.clear(); + }; + + let nextId = 1; + const requests = new Map(); + const events = new Map(); + socket.addEventListener("message", ({ data }) => { + let message; + try { + message = JSON.parse(data); + } catch { + failPending(new Error("Chrome DevTools returned malformed JSON")); + return; + } + if (message.id) { + const request = requests.get(message.id); + if (!request) return; + requests.delete(message.id); + clearTimeout(request.timer); + if (message.error) request.reject(new Error(`${message.error.message} (${request.method})`)); + else request.resolve(message.result); + return; + } + const listeners = events.get(message.method) ?? []; + events.delete(message.method); + listeners.forEach(({ resolve, timer }) => { + clearTimeout(timer); + resolve(message.params); + }); + }); + socket.addEventListener("close", () => failPending(new Error("Chrome DevTools socket closed"))); + socket.addEventListener("error", () => failPending(new Error("Chrome DevTools socket error"))); + + return { + call(method, params = {}) { + return new Promise((resolve, reject) => { + if (closeError) { + reject(closeError); + return; + } + const id = nextId; + nextId += 1; + const timer = setTimeout(() => { + requests.delete(id); + reject(new Error(`Chrome DevTools ${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + requests.set(id, { method, resolve, reject, timer }); + try { + socket.send(JSON.stringify({ id, method, params })); + } catch (error) { + clearTimeout(timer); + requests.delete(id); + reject(error); + } + }); + }, + waitFor(method) { + return new Promise((resolve, reject) => { + if (closeError) { + reject(closeError); + return; + } + const timer = setTimeout(() => { + const listeners = events.get(method) ?? []; + events.set(method, listeners.filter((listener) => listener.timer !== timer)); + reject(new Error(`Chrome DevTools ${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + events.set(method, [...(events.get(method) ?? []), { resolve, reject, timer }]); + }); + }, + close(error = new Error("Chrome DevTools session closed")) { + failPending(error); + socket.close(); + }, + }; +} + +async function openCdp(port, { + chrome = null, + fetcher = fetch, + webSocketFactory = (url) => new WebSocket(url), + retryDelay = delay, + timeoutMs = 10_000, +} = {}) { + const target = await withTimeout((async () => { + while (true) { + chrome?.assertRunning(); + try { + const response = await withTimeout( + fetcher(`http://127.0.0.1:${port}/json/list`), + "Chrome DevTools target request", + Math.min(timeoutMs, 2_000), + ); + if (!response.ok) throw new Error(`Chrome DevTools target request returned HTTP ${response.status}`); + const targets = await withTimeout( + response.json(), + "Chrome DevTools target response", + Math.min(timeoutMs, 2_000), + ); + const page = targets.find((candidate) => candidate.type === "page"); + if (page?.webSocketDebuggerUrl) return page; + } catch { + // Chrome may expose the port before it has registered a page target. + } + await retryDelay(100); + } + })(), "Chrome DevTools target discovery", timeoutMs); + + const socket = webSocketFactory(target.webSocketDebuggerUrl); + await withTimeout(new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("close", () => reject(new Error("Chrome DevTools socket closed before opening")), { once: true }); + socket.addEventListener("error", () => reject(new Error("Chrome DevTools socket error before opening")), { once: true }); + }), "Chrome DevTools WebSocket connection", timeoutMs); + return createCdpSession(socket, { timeoutMs }); +} + +async function navigateAndWait(cdp, url) { + const loaded = cdp.waitFor("Page.loadEventFired"); + await Promise.all([ + cdp.call("Page.navigate", { url }), + loaded, + ]); +} + +function childHasExited(child) { + return child.exitCode !== null || child.signalCode !== null; +} + +function waitForChildExit(child) { + if (childHasExited(child)) return Promise.resolve(); + return new Promise((resolve, reject) => { + child.once("close", resolve); + child.once("error", reject); + }); +} + +async function terminateChrome(child, { gracefulTimeoutMs = 5_000, killTimeoutMs = 2_000 } = {}) { + if (!child || childHasExited(child)) return; + child.kill("SIGTERM"); + try { + await withTimeout(waitForChildExit(child), "Chrome graceful shutdown", gracefulTimeoutMs); + } catch { + if (childHasExited(child)) return; + child.kill("SIGKILL"); + await withTimeout(waitForChildExit(child), "Chrome forced shutdown", killTimeoutMs); + } +} + +async function measure() { + const directory = mkdtempSync(join(tmpdir(), "salvo-layout-")); + const file = join(directory, "layout.html"); + const profile = join(directory, "chrome-profile"); + let child = null; + let cdp = null; + let chrome = null; + let stopObserving = null; + try { + writeFileSync(file, layoutHtml()); + child = spawn(chromeBinary(), [ + "--headless=new", + "--no-sandbox", + "--disable-gpu", + "--disable-background-networking", + "--disable-component-update", + "--disable-default-apps", + "--disable-sync", + "--no-first-run", + "--no-service-autorun", + "--password-store=basic", + "--use-mock-keychain", + `--user-data-dir=${profile}`, + "--remote-debugging-port=0", + "--remote-allow-origins=*", + "about:blank", + ], { stdio: ["ignore", "ignore", "pipe"] }); + chrome = observeChrome(child); + const cdpPort = await waitForDevToolsPort(profile, chrome); + cdp = await openCdp(cdpPort, { chrome }); + stopObserving = chrome.onFailure((error) => cdp?.close(error)); + await cdp.call("Page.enable"); + await cdp.call("Emulation.setDeviceMetricsOverride", { + width: 360, + height: 800, + deviceScaleFactor: 1, + mobile: false, + screenWidth: 360, + screenHeight: 800, + }); + await navigateAndWait(cdp, pathToFileURL(file).href); + const { result } = await cdp.call("Runtime.evaluate", { + expression: "document.documentElement.dataset.layout", + returnByValue: true, + }); + assert.equal(typeof result.value, "string", "Chrome did not return layout data"); + return JSON.parse(Buffer.from(result.value, "base64").toString("utf8")); + } catch (error) { + throw chrome?.diagnose(error) ?? error; + } finally { + stopObserving?.(); + cdp?.close(); + let teardownError = null; + try { + await terminateChrome(child); + } catch (error) { + teardownError = error; + } + rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + if (teardownError) throw teardownError; + } +} + +test("phone layout uses computed Telegram board and safe-modal geometry without changing web or native replay overflow", async () => { + const { telegram, web, android: native } = await measure(); + + for (const layout of [telegram, web, native]) { + assert.equal(layout.document.width, 360, "layout test must use a 360px CSS viewport"); + assert.equal(layout.document.height, 800, "layout test must use an 800px CSS viewport"); + assert.ok(layout.setup.scrollWidth <= layout.setup.clientWidth, "10x10 setup board must fit its phone container"); + } + assert.ok(telegram.document.scrollWidth <= telegram.document.clientWidth, "Telegram page must not overflow horizontally"); + assert.ok(telegram.replay.scrollWidth <= telegram.replay.clientWidth, "Telegram 16x16 replay must not overflow horizontally"); + assert.ok(telegram.board.scrollWidth <= telegram.board.clientWidth, "Telegram 16x16 replay board must fit its container"); + assert.equal(telegram.replay.overflowX, "hidden", "Telegram replay must clip horizontal board overflow"); + assert.ok(telegram.modal.top >= 40, "Telegram result modal must stay below the top safe inset"); + assert.ok(telegram.modal.bottom <= 760, "Telegram result modal must stay above the bottom safe inset"); + + for (const layout of [web, native]) { + assert.equal(layout.replay.overflowX, "auto", "web and native retain the 16x16 replay scroller"); + } +}); diff --git a/tests/telegram-mini-app-auth.test.mjs b/tests/telegram-mini-app-auth.test.mjs new file mode 100644 index 0000000..d926439 --- /dev/null +++ b/tests/telegram-mini-app-auth.test.mjs @@ -0,0 +1,261 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { webcrypto } from "node:crypto"; + +import { verifyTelegramMiniAppInitData } from "../worker/telegram-mini-app-auth.js"; + +const cryptoApi = globalThis.crypto ?? webcrypto; +const textEncoder = new TextEncoder(); +const authErrorMessage = "Telegram Mini App authentication failed"; +const botToken = "123456:test-bot-token"; +const now = 1784232120; + +function telegramUser(overrides = {}) { + return { + id: 8710001168, + first_name: "Dima", + last_name: "Kosarevsky", + username: "agent_axiom", + language_code: "ru", + photo_url: "https://t.me/i/userpic/320/avatar.jpg", + ...overrides, + }; +} + +function launchFields(overrides = {}) { + return { + auth_date: "1784232000", + query_id: "AAHdF6IQAAAAAN0XohDhrOrc", + start_param: "room_ABCD", + user: JSON.stringify(telegramUser()), + ...overrides, + }; +} + +test("Mini App initData verifies and normalizes the Telegram user", async () => { + const initData = await signInitData(launchFields(), botToken); + + assert.deepEqual( + await verifyTelegramMiniAppInitData(initData, botToken, { + now: 1784232120, + maxAgeSeconds: 300, + maxFutureSeconds: 60, + }), + { + user: { + provider: "telegram", + id: "8710001168", + name: "Dima Kosarevsky", + username: "agent_axiom", + photoUrl: "https://t.me/i/userpic/320/avatar.jpg", + }, + languageCode: "ru", + startParam: "room_ABCD", + }, + ); +}); + +test("Mini App initData verifies decoded query values", async () => { + const initData = await signInitData( + launchFields({ + start_param: "room A+B", + user: JSON.stringify(telegramUser({ first_name: "Dima Ivan" })), + }), + botToken, + ); + + const result = await verifyTelegramMiniAppInitData(initData, botToken, { now }); + + assert.equal(result.user.name, "Dima Ivan Kosarevsky"); + assert.equal(result.startParam, "room A+B"); +}); + +test("Mini App initData includes the optional signature in the bot-token HMAC", async () => { + const initData = await signInitData( + launchFields({ signature: "telegram-third-party-signature" }), + botToken, + ); + + const result = await verifyTelegramMiniAppInitData(initData, botToken, { now }); + + assert.equal(result.user.id, "8710001168"); +}); + +test("Mini App initData rejects tampering", async () => { + const initData = await signInitData(launchFields(), botToken); + + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData.replace("Dima", "Mallory"), botToken, { now })); +}); + +test("Mini App initData rejects duplicate fields even when they are signed", async () => { + const initData = await signInitDataEntries( + [...Object.entries(launchFields()), ["auth_date", "1784232000"]], + botToken, + ); + + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); +}); + +test("Mini App initData rejects unknown top-level fields even when they are signed", async () => { + const initData = await signInitData({ ...launchFields(), unexpected: "value" }, botToken); + + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); +}); + +test("Mini App initData rejects malformed percent encoding before parsing", async () => { + const entries = Object.entries(launchFields({ user: "%ZZ" })); + const initData = await signInitDataEntries(entries, botToken, { user: "%ZZ" }); + + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); +}); + +test("Mini App initData rejects missing configuration and required fields", async () => { + const valid = await signInitData(launchFields(), botToken); + await assertAuthFailure(() => verifyTelegramMiniAppInitData(valid, "", { now })); + + for (const requiredField of ["auth_date", "user"]) { + const fields = launchFields(); + delete fields[requiredField]; + const initData = await signInitData(fields, botToken); + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); + } + + await assertAuthFailure(() => + verifyTelegramMiniAppInitData(valid.replace(/&hash=[a-f0-9]+$/, ""), botToken, { now }), + ); +}); + +test("Mini App initData rejects empty and oversized input", async () => { + await assertAuthFailure(() => verifyTelegramMiniAppInitData("", botToken, { now })); + + const initData = await signInitData(launchFields({ start_param: "x".repeat(16 * 1024) }), botToken); + assert.ok(textEncoder.encode(initData).byteLength > 16 * 1024); + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); +}); + +test("Mini App initData rejects stale and future auth dates", async () => { + for (const authDate of [String(now - 301), String(now + 61)]) { + const initData = await signInitData(launchFields({ auth_date: authDate }), botToken); + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); + } +}); + +test("Mini App initData rejects malformed auth dates and hashes", async () => { + for (const authDate of ["", "1e9", "1784232000.5", "-1784232000"]) { + const initData = await signInitData(launchFields({ auth_date: authDate }), botToken); + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); + } + + const valid = await signInitData(launchFields(), botToken); + for (const hash of ["not-hex", "A".repeat(64), "g".repeat(64)]) { + await assertAuthFailure(() => + verifyTelegramMiniAppInitData(valid.replace(/hash=[a-f0-9]+/, `hash=${hash}`), botToken, { now }), + ); + } +}); + +test("Mini App initData rejects malformed user JSON without leaking launch data", async () => { + const rawUser = "TOP_SECRET_INIT_DATA"; + const initData = await signInitData(launchFields({ user: rawUser }), botToken); + + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now }), rawUser); +}); + +test("Mini App initData rejects invalid, unsafe, and bot users", async () => { + const invalidUsers = [ + telegramUser({ id: 0 }), + telegramUser({ id: -1 }), + telegramUser({ id: 1.5 }), + telegramUser({ id: "8710001168" }), + telegramUser({ id: 2 ** 52 }), + telegramUser({ is_bot: true }), + telegramUser({ is_bot: "false" }), + null, + [], + ]; + + for (const user of invalidUsers) { + const initData = await signInitData(launchFields({ user: JSON.stringify(user) }), botToken); + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); + } +}); + +test("Mini App initData accepts the inclusive 52-bit Telegram ID ceiling", async () => { + const initData = await signInitData( + launchFields({ user: JSON.stringify(telegramUser({ id: 2 ** 52 - 1 })) }), + botToken, + ); + + const result = await verifyTelegramMiniAppInitData(initData, botToken, { now }); + + assert.equal(result.user.id, "4503599627370495"); +}); + +test("Mini App initData rejects invalid and overlong user strings", async () => { + const invalidUsers = [ + telegramUser({ first_name: "x".repeat(129) }), + telegramUser({ username: "x".repeat(65) }), + telegramUser({ language_code: "x".repeat(36) }), + telegramUser({ photo_url: `https://example.com/${"x".repeat(2030)}` }), + telegramUser({ photo_url: "http://example.com/avatar.jpg" }), + telegramUser({ photo_url: "javascript:alert(1)" }), + telegramUser({ first_name: 42 }), + telegramUser({ username: { value: "agent_axiom" } }), + telegramUser({ language_code: ["ru"] }), + telegramUser({ photo_url: null }), + ]; + + for (const user of invalidUsers) { + const initData = await signInitData(launchFields({ user: JSON.stringify(user) }), botToken); + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); + } +}); + +test("Mini App initData rejects an overlong start parameter", async () => { + const initData = await signInitData(launchFields({ start_param: "x".repeat(513) }), botToken); + + await assertAuthFailure(() => verifyTelegramMiniAppInitData(initData, botToken, { now })); +}); + +async function signInitData(fields, botToken) { + return signInitDataEntries(Object.entries(fields), botToken); +} + +async function signInitDataEntries(entries, botToken, rawValues = {}) { + const dataCheckString = entries + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + const secret = await hmac(textEncoder.encode("WebAppData"), botToken); + const hash = bytesToHex(await hmac(secret, dataCheckString)); + const encodedFields = entries.map(([key, value]) => { + const encodedValue = Object.hasOwn(rawValues, key) ? rawValues[key] : encodeURIComponent(value); + return `${encodeURIComponent(key)}=${encodedValue}`; + }); + return [...encodedFields, `hash=${hash}`].join("&"); +} + +async function hmac(secret, value) { + const key = await cryptoApi.subtle.importKey( + "raw", + secret, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array(await cryptoApi.subtle.sign("HMAC", key, textEncoder.encode(value))); +} + +function bytesToHex(bytes) { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function assertAuthFailure(operation, forbiddenText = "") { + await assert.rejects(operation, (error) => { + assert.equal(error?.message, authErrorMessage); + if (forbiddenText) { + assert.equal(error.message.includes(forbiddenText), false); + } + return true; + }); +} diff --git a/tests/telegram-mini-app-client.test.mjs b/tests/telegram-mini-app-client.test.mjs new file mode 100644 index 0000000..de06af9 --- /dev/null +++ b/tests/telegram-mini-app-client.test.mjs @@ -0,0 +1,494 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTelegramMiniAppAuthClient } from "../src/telegram-mini-app-auth.js"; + +const responseByteLimit = 16 * 1024; +const validToken = "a".repeat(43); +const validUser = Object.freeze({ + provider: "telegram", + id: "42", + name: "Captain Test", + username: "captain", + photoUrl: "https://example.test/captain.jpg", +}); + +test("authenticate sends the exact Mini App request and returns the public session", async () => { + const requests = []; + const payload = { token: validToken, user: validUser }; + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test/api///", + async fetcher(input, init) { + requests.push([input, init]); + return jsonResponse(payload); + }, + }); + + assert.deepEqual(await client.authenticate("signed-launch-data"), payload); + assert.equal(requests.length, 1); + assert.equal(requests[0][0], "https://worker.test/api/auth/telegram/miniapp"); + const { signal, ...requestInit } = requests[0][1]; + assert.equal(signal instanceof AbortSignal, true); + assert.deepEqual(requestInit, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: '{"initData":"signed-launch-data"}', + }); +}); + +test("constructor rejects unsafe worker URLs, fetchers, and timeouts", () => { + for (const workerUrl of [ + undefined, + null, + "", + " https://worker.test", + "worker.test", + "http://worker.test", + "https://user@worker.test", + "https://@worker.test", + "https://worker.test:443", + "https://worker.test:8443", + "https://worker.test/?debug=1", + "https://worker.test/?", + "https://worker.test/#debug", + "https://worker.test/#", + ]) { + assert.throws(() => createTelegramMiniAppAuthClient({ + workerUrl, + fetcher: async () => {}, + }), { name: "TypeError" }, String(workerUrl)); + } + + for (const fetcher of [null, false, {}, "fetch"]) { + assert.throws(() => createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + fetcher, + }), { name: "TypeError" }); + } + + for (const timeoutMs of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648]) { + assert.throws(() => createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + fetcher: async () => {}, + timeoutMs, + }), { name: "TypeError" }, String(timeoutMs)); + } +}); + +test("authenticate uses a 10-second default deadline", async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const timerHandle = {}; + let scheduledDelay; + let clearedHandle; + globalThis.setTimeout = (_callback, delay) => { + scheduledDelay = delay; + return timerHandle; + }; + globalThis.clearTimeout = (handle) => { + clearedHandle = handle; + }; + + try { + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + fetcher: async () => jsonResponse({ token: validToken, user: validUser }), + }); + + await client.authenticate("signed-launch-data"); + assert.equal(scheduledDelay, 10_000); + assert.equal(clearedHandle, timerHandle); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + +test("initData must be a non-empty string no larger than 16 KiB by UTF-8 bytes", async () => { + let fetchCalls = 0; + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + async fetcher() { + fetchCalls += 1; + return jsonResponse({ token: validToken, user: validUser }); + }, + }); + + for (const initData of [undefined, null, "", 42, {}, [], "a".repeat(responseByteLimit + 1)]) { + await assert.rejects(client.authenticate(initData), { name: "TypeError" }, String(initData)); + } + await assert.rejects(client.authenticate(`${"a".repeat(responseByteLimit - 1)}é`), { + name: "TypeError", + }); + + await client.authenticate("é".repeat(responseByteLimit / 2)); + assert.equal(fetchCalls, 1); +}); + +test("authenticate rejects malformed caller abort signals before fetching", async () => { + let fetchCalls = 0; + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + async fetcher() { + fetchCalls += 1; + return jsonResponse({ token: validToken, user: validUser }); + }, + }); + + for (const signal of [false, {}, { aborted: false }, new EventTarget()]) { + await assert.rejects(client.authenticate("signed", { signal }), { name: "TypeError" }); + } + assert.equal(fetchCalls, 0); +}); + +test("responses require successful JSON with a body no larger than 16 KiB", async () => { + const providerSecret = "sensitive-provider-description"; + const cases = [ + new Response(JSON.stringify({ error: providerSecret }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + new Response(`${providerSecret}`, { + status: 502, + headers: { "Content-Type": "text/html" }, + }), + new Response(JSON.stringify({ token: validToken, user: validUser }), { + status: 200, + headers: { "Content-Type": "application/problem+json" }, + }), + new Response(providerSecret, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + new Response(JSON.stringify({ + token: validToken, + user: validUser, + padding: `${providerSecret}${"x".repeat(responseByteLimit)}`, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ]; + + for (const response of cases) { + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + fetcher: async () => response, + }); + const error = await rejected(client.authenticate("signed-launch-data")); + assertGenericClientError(error, response.status, providerSecret); + } +}); + +test("non-success responses cancel stalled bodies before request cleanup", async () => { + const caller = trackedAbortController(); + const stalled = stalledRejectedResponse(503); + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + timeoutMs: 1_000, + fetcher: async () => stalled.response, + }); + + const pending = client.authenticate("signed-launch-data", { signal: caller.signal }); + const firstEvent = await Promise.race([ + stalled.cancelStarted.then(() => "cancelled"), + pending.then(() => "settled", () => "settled"), + ]); + + assert.equal(firstEvent, "cancelled"); + assert.deepEqual(caller.listenerCounts(), { added: 1, removed: 0 }); + stalled.releaseCancel(); + const error = await rejectedWithin(pending); + assertGenericClientError(error, 503); + assert.equal(stalled.cancelCalls(), 1); + assert.deepEqual(caller.listenerCounts(), { added: 1, removed: 1 }); +}); + +test("response validation accepts only exact tokens and public Telegram users", async () => { + const malformed = [ + null, + [], + { token: validToken }, + { token: validToken, user: validUser, refreshToken: "refresh-secret" }, + { token: "a".repeat(42), user: validUser }, + { token: "a".repeat(44), user: validUser }, + { token: `${"a".repeat(42)}=`, user: validUser }, + { token: `${"a".repeat(42)}.`, user: validUser }, + { token: validToken, user: null }, + { token: validToken, user: { ...validUser, provider: "evil" } }, + { token: validToken, user: { ...validUser, id: "" } }, + { token: validToken, user: { ...validUser, id: "x".repeat(129) } }, + { token: validToken, user: { ...validUser, name: 42 } }, + { token: validToken, user: { ...validUser, name: "x".repeat(257) } }, + { token: validToken, user: { ...validUser, username: "x".repeat(129) } }, + { token: validToken, user: { ...validUser, photoUrl: "x".repeat(2049) } }, + { token: validToken, user: { ...validUser, privateClaim: "provider-secret" } }, + ]; + + for (const payload of malformed) { + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + fetcher: async () => jsonResponse(payload), + }); + const error = await rejected(client.authenticate("signed-launch-data")); + assertGenericClientError(error, 200, "refresh-secret", "provider-secret"); + } +}); + +test("network failures become stable redacted errors with only safe HTTP status", async () => { + const cases = [ + [new Error("socket failure with provider-secret"), 0], + [Object.assign(new Error("upstream unavailable with provider-secret"), { status: 503 }), 503], + [Object.assign(new Error("invalid status with provider-secret"), { status: 999 }), 0], + [Object.assign(new Error("string status with provider-secret"), { status: "503" }), 0], + ]; + + for (const [sourceError, status] of cases) { + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + fetcher: async () => { throw sourceError; }, + }); + const error = await rejected(client.authenticate("signed-launch-data")); + assertGenericClientError(error, status, "provider-secret", "signed-launch-data"); + assert.deepEqual(Object.keys(error).sort(), ["status"]); + } +}); + +test("request timeout aborts stalled fetches and detaches caller listeners", async () => { + const caller = trackedAbortController(); + let requestSignal; + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + timeoutMs: 20, + fetcher: async (_input, init) => { + requestSignal = init.signal; + return new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => { + reject(new Error("pending-fetch-provider-secret")); + }, { once: true }); + }); + }, + }); + + const error = await rejectedWithin(client.authenticate("signed", { signal: caller.signal })); + assertGenericClientError(error, 0, "pending-fetch-provider-secret", "signed"); + assert.equal(requestSignal.aborted, true); + assert.deepEqual(caller.listenerCounts(), { added: 1, removed: 1 }); +}); + +test("an already-aborted caller rejects before fetch without leaking its reason", async () => { + const caller = new AbortController(); + caller.abort(new Error("caller-reason-secret")); + let fetchCalls = 0; + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + async fetcher() { + fetchCalls += 1; + return jsonResponse({ token: validToken, user: validUser }); + }, + }); + + const error = await rejected(client.authenticate("signed-launch-data", { signal: caller.signal })); + assertGenericClientError(error, 0, "caller-reason-secret", "signed-launch-data"); + assert.equal(fetchCalls, 0); +}); + +test("caller cancellation aborts an in-flight request and cleans up its listener", async () => { + const caller = trackedAbortController(); + const fetchStarted = deferred(); + let requestSignal; + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + timeoutMs: 1_000, + fetcher: async (_input, init) => { + requestSignal = init.signal; + fetchStarted.resolve(); + return new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => { + reject(new Error("caller-fetch-provider-secret")); + }, { once: true }); + }); + }, + }); + + const pending = client.authenticate("signed-launch-data", { signal: caller.signal }); + await fetchStarted.promise; + caller.controller.abort(new Error("caller-reason-secret")); + const error = await rejectedWithin(pending); + + assertGenericClientError(error, 0, "caller-fetch-provider-secret", "caller-reason-secret"); + assert.equal(requestSignal.aborted, true); + assert.deepEqual(caller.listenerCounts(), { added: 1, removed: 1 }); +}); + +test("caller cancellation during body read cancels the reader and preserves response status", async () => { + const caller = trackedAbortController(); + const stalled = stallingJsonResponse(); + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + timeoutMs: 1_000, + fetcher: async () => stalled.response, + }); + + const pending = client.authenticate("signed-launch-data", { signal: caller.signal }); + await stalled.readStalled; + caller.controller.abort(new Error("caller-body-reason-secret")); + const error = await rejectedWithin(pending); + + assertGenericClientError(error, 200, "partial-body-provider-secret", "caller-body-reason-secret"); + assert.equal(stalled.cancelCalls(), 1); + assert.deepEqual(caller.listenerCounts(), { added: 1, removed: 1 }); +}); + +test("successful requests clear their deadline and detach caller cancellation", async () => { + const caller = trackedAbortController(); + let requestSignal; + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + timeoutMs: 20, + async fetcher(_input, init) { + requestSignal = init.signal; + return jsonResponse({ token: validToken, user: validUser }); + }, + }); + + await client.authenticate("signed-launch-data", { signal: caller.signal }); + assert.deepEqual(caller.listenerCounts(), { added: 1, removed: 1 }); + await delay(40); + caller.controller.abort(new Error("late-caller-secret")); + assert.equal(requestSignal.aborted, false); +}); + +async function rejected(promise) { + try { + await promise; + } catch (error) { + return error; + } + assert.fail("expected promise to reject"); +} + +async function rejectedWithin(promise, timeoutMs = 500) { + let watchdog; + try { + return await Promise.race([ + rejected(promise), + new Promise((_resolve, reject) => { + watchdog = setTimeout(() => reject(new Error("test watchdog expired")), timeoutMs); + }), + ]); + } finally { + clearTimeout(watchdog); + } +} + +function jsonResponse(payload, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); +} + +function assertGenericClientError(error, status, ...secrets) { + assert.equal(error instanceof Error, true); + assert.equal(error.message, "Telegram authentication unavailable"); + assert.equal(error.status, status); + for (const secret of secrets) { + assert.equal(error.message.includes(secret), false); + assert.equal(String(error).includes(secret), false); + } +} + +function trackedAbortController() { + const controller = new AbortController(); + const signal = controller.signal; + const addEventListener = signal.addEventListener.bind(signal); + const removeEventListener = signal.removeEventListener.bind(signal); + let added = 0; + let removed = 0; + signal.addEventListener = (type, listener, options) => { + if (type === "abort") added += 1; + return addEventListener(type, listener, options); + }; + signal.removeEventListener = (type, listener, options) => { + if (type === "abort") removed += 1; + return removeEventListener(type, listener, options); + }; + return { + controller, + signal, + listenerCounts: () => ({ added, removed }), + }; +} + +function stallingJsonResponse() { + const readStalled = deferred(); + const never = new Promise(() => {}); + let reads = 0; + let cancellations = 0; + const reader = { + read() { + reads += 1; + if (reads === 1) { + return Promise.resolve({ + done: false, + value: new TextEncoder().encode('{"token":"partial-body-provider-secret'), + }); + } + readStalled.resolve(); + return never; + }, + cancel() { + cancellations += 1; + return Promise.resolve(); + }, + releaseLock() {}, + }; + return { + response: { + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + body: { getReader: () => reader }, + }, + readStalled: readStalled.promise, + cancelCalls: () => cancellations, + }; +} + +function stalledRejectedResponse(status) { + const started = deferred(); + const cancellation = deferred(); + let cancellations = 0; + return { + response: { + ok: false, + status, + headers: new Headers({ "Content-Type": "application/json" }), + body: { + cancel() { + cancellations += 1; + started.resolve(); + return cancellation.promise; + }, + }, + }, + cancelStarted: started.promise, + releaseCancel: cancellation.resolve, + cancelCalls: () => cancellations, + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/tests/telegram-mini-app-worker.test.mjs b/tests/telegram-mini-app-worker.test.mjs new file mode 100644 index 0000000..85e46ea --- /dev/null +++ b/tests/telegram-mini-app-worker.test.mjs @@ -0,0 +1,414 @@ +import assert from "node:assert/strict"; +import { webcrypto } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; + +import { createTelegramMiniAppAuthClient } from "../src/telegram-mini-app-auth.js"; +import worker from "../worker/index.js"; +import { verifyTelegramMiniAppInitData } from "../worker/telegram-mini-app-auth.js"; + +const cryptoApi = globalThis.crypto ?? webcrypto; +const textEncoder = new TextEncoder(); +const profileSchema = await readFile(new URL("../migrations/0001_player_profiles.sql", import.meta.url), "utf8"); +const sessionSchema = await readFile(new URL("../migrations/0003_mobile_oidc_sessions.sql", import.meta.url), "utf8"); +const botToken = "123456:test-bot-token"; +const queryId = "AAHdF6IQAAAAAN0XohDhrOrc"; +const telegramUserJson = JSON.stringify({ + id: 8710001168, + first_name: "Dima", + last_name: "Kosarevsky", + username: "agent_axiom", + language_code: "ru", + photo_url: "https://t.me/i/userpic/320/avatar.jpg", +}); +const expectedUser = { + provider: "telegram", + id: "8710001168", + name: "Dima Kosarevsky", + username: "agent_axiom", + photoUrl: "https://t.me/i/userpic/320/avatar.jpg", +}; +const authenticationFailure = JSON.stringify({ error: "Telegram Mini App authentication failed" }); +const maxInitDataBytes = 16 * 1024; +const telegramMiniAppJsonEnvelopeBytes = 15; +const maxTelegramMiniAppJsonBytes = maxInitDataBytes + telegramMiniAppJsonEnvelopeBytes; + +test("Telegram Mini App auth creates an opaque session for the existing Telegram identity", async (t) => { + const db = memoryD1(t); + const initData = await signedInitData(); + + const response = await postMiniApp({ initData }, { DB: db, TELEGRAM_BOT_TOKEN: botToken }); + + assert.equal(response.status, 200); + const payload = await response.json(); + assert.deepEqual(payload.user, expectedUser); + assert.match(payload.token, /^[A-Za-z0-9_-]{43}$/); + assert.equal(db.queryOne("SELECT user_key FROM auth_sessions").user_key, "telegram:8710001168"); + + const me = await worker.fetch( + new Request("https://worker.test/auth/me", { + headers: { Authorization: `Bearer ${payload.token}` }, + }), + { DB: db }, + ); + assert.equal(me.status, 200); + assert.deepEqual(await me.json(), { user: payload.user }); +}); + +test("Mini App client and Worker accept the exact raw initData boundary", async (t) => { + const db = memoryD1(t); + const initData = await signedInitDataAtByteLength(maxInitDataBytes); + const oversizedInitData = `${initData}x`; + let fetchCalls = 0; + const client = createTelegramMiniAppAuthClient({ + workerUrl: "https://worker.test", + async fetcher(url, init) { + fetchCalls += 1; + assert.equal(textEncoder.encode(init.body).byteLength, maxTelegramMiniAppJsonBytes); + return worker.fetch(new Request(url, init), { DB: db, TELEGRAM_BOT_TOKEN: botToken }); + }, + }); + + const payload = await client.authenticate(initData); + assert.deepEqual(payload.user, expectedUser); + assert.match(payload.token, /^[A-Za-z0-9_-]{43}$/); + assert.equal(fetchCalls, 1); + + await assert.rejects(client.authenticate(oversizedInitData), { name: "TypeError" }); + await assert.rejects( + verifyTelegramMiniAppInitData(oversizedInitData, botToken), + { message: "Telegram Mini App authentication failed" }, + ); + assert.equal(fetchCalls, 1); +}); + +test("Telegram Mini App verification rejects invalid policy and query encoding", async () => { + const initData = await signedInitData(); + const cases = [ + [initData, { now: Number.NaN }], + [initData, { maxAgeSeconds: -1 }], + [initData, { maxFutureSeconds: -1 }], + ["auth_date", undefined], + ]; + + for (const [candidate, options] of cases) { + await assert.rejects( + verifyTelegramMiniAppInitData(candidate, botToken, options), + { message: "Telegram Mini App authentication failed" }, + ); + } +}); + +test("Telegram Mini App verification rejects signed unsafe epochs", async () => { + const initData = await signedInitData({ + auth_date: String(Number.MAX_SAFE_INTEGER + 1), + }); + + await assert.rejects( + verifyTelegramMiniAppInitData(initData, botToken, { + now: Number.MAX_SAFE_INTEGER, + maxAgeSeconds: Number.MAX_SAFE_INTEGER, + maxFutureSeconds: Number.MAX_SAFE_INTEGER, + }), + { message: "Telegram Mini App authentication failed" }, + ); +}); + +test("Telegram Mini App verification normalizes absent optional profile fields", async () => { + const initData = await signedInitData({ + start_param: "", + user: JSON.stringify({ id: 42, first_name: " Captain " }), + }); + + assert.deepEqual( + await verifyTelegramMiniAppInitData(initData, botToken), + { + user: { + provider: "telegram", + id: "42", + name: "Captain", + username: "", + photoUrl: "", + }, + languageCode: "", + startParam: "", + }, + ); +}); + +test("Telegram Mini App verification rejects an unparseable signed photo URL", async () => { + const initData = await signedInitData({ + user: JSON.stringify({ + id: 42, + first_name: "Captain", + photo_url: "not a URL", + }), + }); + + await assert.rejects( + verifyTelegramMiniAppInitData(initData, botToken), + { message: "Telegram Mini App authentication failed" }, + ); +}); + +test("Telegram Mini App auth route matches only the exact path and POST method", async () => { + for (const [method, path] of [ + ["GET", "/auth/telegram/miniapp"], + ["PUT", "/auth/telegram/miniapp"], + ["POST", "/auth/telegram/miniapp/"], + ["POST", "/AUTH/telegram/miniapp"], + ]) { + const response = await worker.fetch(new Request(`https://worker.test${path}`, { method }), {}); + assert.equal(response.status, 404, `${method} ${path}`); + assert.deepEqual(await response.json(), { error: "Not found" }); + } +}); + +test("Telegram Mini App auth classifies launch and service failures with one redacted response", async (t) => { + const db = memoryD1(t); + const now = Math.floor(Date.now() / 1_000); + const initData = await signedInitData({ auth_date: String(now) }); + const staleInitData = await signedInitData({ auth_date: String(now - 600) }); + const tamperedInitData = initData.replace(/hash=([a-f0-9])/, (_, first) => `hash=${first === "a" ? "b" : "a"}`); + const hash = new URLSearchParams(initData).get("hash"); + const oversizedEnvelopeSecret = "oversized-envelope-secret"; + const oversizedInitData = `${ + "x".repeat(maxInitDataBytes + 1 - oversizedEnvelopeSecret.length) + }${oversizedEnvelopeSecret}`; + assert.equal( + textEncoder.encode(JSON.stringify({ initData: oversizedInitData })).byteLength, + maxTelegramMiniAppJsonBytes + 1, + ); + const upsertFailureSecret = `D1 upsert failure: ${telegramUserJson}`; + const sessionFailureSecret = `D1 session failure: ${queryId}`; + const upsertFailure = { + prepare(sql) { + if (sql.includes("INSERT INTO users")) throw new Error(upsertFailureSecret); + return db.prepare(sql); + }, + }; + const sessionFailure = { + prepare(sql) { + if (sql.includes("INSERT INTO auth_sessions")) throw new Error(sessionFailureSecret); + return db.prepare(sql); + }, + }; + const inaccessibleConfig = {}; + Object.defineProperty(inaccessibleConfig, "DB", { + get() { + throw new Error("private Worker binding failure"); + }, + }); + const cases = [ + { + name: "wrong content type", + status: 401, + request: () => postMiniApp({ initData }, { DB: db, TELEGRAM_BOT_TOKEN: botToken }, "text/plain"), + }, + { + name: "extra JSON field", + status: 401, + request: () => postMiniApp({ initData, callback: "https://attacker.test" }, { DB: db, TELEGRAM_BOT_TOKEN: botToken }), + }, + { + name: "missing initData", + status: 401, + request: () => postMiniApp({}, { DB: db, TELEGRAM_BOT_TOKEN: botToken }), + }, + { + name: "malformed JSON", + status: 401, + request: () => rawMiniAppRequest("{not-json", { DB: db, TELEGRAM_BOT_TOKEN: botToken }), + }, + { + name: "oversized JSON envelope", + status: 401, + request: () => postMiniApp({ initData: oversizedInitData }, { DB: db, TELEGRAM_BOT_TOKEN: botToken }), + }, + { + name: "stale initData", + status: 401, + request: () => postMiniApp({ initData: staleInitData }, { DB: db, TELEGRAM_BOT_TOKEN: botToken }), + }, + { + name: "tampered initData", + status: 401, + request: () => postMiniApp({ initData: tamperedInitData }, { DB: db, TELEGRAM_BOT_TOKEN: botToken }), + }, + { + name: "missing D1 binding", + status: 503, + request: () => postMiniApp({ initData }, { TELEGRAM_BOT_TOKEN: botToken }), + }, + { + name: "missing bot token", + status: 503, + request: () => postMiniApp({ initData }, { DB: db }), + }, + { + name: "empty bot token", + status: 503, + request: () => postMiniApp({ initData }, { DB: db, TELEGRAM_BOT_TOKEN: "" }), + }, + { + name: "blank bot token", + status: 503, + request: () => postMiniApp({ initData }, { DB: db, TELEGRAM_BOT_TOKEN: " \t" }), + }, + { + name: "inaccessible Worker configuration", + status: 503, + request: () => postMiniApp({ initData }, inaccessibleConfig), + }, + { + name: "D1 upsert failure", + status: 503, + request: () => postMiniApp({ initData }, { DB: upsertFailure, TELEGRAM_BOT_TOKEN: botToken }), + }, + { + name: "D1 session creation failure", + status: 503, + request: () => postMiniApp({ initData }, { DB: sessionFailure, TELEGRAM_BOT_TOKEN: botToken }), + }, + ]; + + const responseBodies = new Set(); + for (const rejection of cases) { + const response = await rejection.request(); + const body = await response.text(); + assert.equal(response.status, rejection.status, rejection.name); + assert.equal(body, authenticationFailure, rejection.name); + responseBodies.add(body); + assertRedacted(body, [ + botToken, + initData, + staleInitData, + tamperedInitData, + hash, + queryId, + telegramUserJson, + oversizedEnvelopeSecret, + upsertFailureSecret, + sessionFailureSecret, + "private Worker binding failure", + ]); + } + assert.deepEqual([...responseBodies], [authenticationFailure]); + assert.equal(db.queryOne("SELECT COUNT(*) AS count FROM auth_sessions").count, 0); +}); + +function memoryD1(t) { + const db = new MemoryD1(); + t.after(() => db.close()); + return db; +} + +class MemoryD1 { + constructor() { + this.database = new DatabaseSync(":memory:"); + this.database.exec("PRAGMA foreign_keys = ON"); + this.database.exec(profileSchema); + this.database.exec(sessionSchema); + } + + prepare(sql) { + return new MemoryStatement(this.database, sql); + } + + queryOne(sql, ...params) { + return this.database.prepare(sql).get(...params) ?? null; + } + + close() { + this.database.close(); + } +} + +class MemoryStatement { + constructor(database, sql, params = []) { + this.database = database; + this.sql = sql; + this.params = params; + } + + bind(...params) { + return new MemoryStatement(this.database, this.sql, params); + } + + async run() { + const result = this.database.prepare(this.sql).run(...this.params); + return { success: true, meta: { changes: Number(result.changes) } }; + } + + async first(columnName) { + const row = this.database.prepare(this.sql).get(...this.params) ?? null; + return columnName ? (row?.[columnName] ?? null) : row; + } + + async all() { + return { results: this.database.prepare(this.sql).all(...this.params), success: true }; + } +} + +async function postMiniApp(body, env, contentType = "application/json") { + return rawMiniAppRequest(JSON.stringify(body), env, contentType); +} + +async function rawMiniAppRequest(body, env, contentType = "application/json") { + return worker.fetch( + new Request("https://worker.test/auth/telegram/miniapp", { + method: "POST", + headers: { "Content-Type": contentType }, + body, + }), + env, + ); +} + +async function signedInitData(overrides = {}) { + const fields = { + auth_date: String(Math.floor(Date.now() / 1_000)), + query_id: queryId, + start_param: "room_ABCD", + user: telegramUserJson, + ...overrides, + }; + const dataCheckString = Object.entries(fields) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + const secret = await hmac(textEncoder.encode("WebAppData"), botToken); + const hash = bytesToHex(await hmac(secret, dataCheckString)); + return new URLSearchParams({ ...fields, hash }).toString(); +} + +async function signedInitDataAtByteLength(byteLength) { + const empty = await signedInitData({ query_id: "" }); + const paddingLength = byteLength - textEncoder.encode(empty).byteLength; + assert.ok(paddingLength > 0); + const initData = await signedInitData({ query_id: "x".repeat(paddingLength) }); + assert.equal(textEncoder.encode(initData).byteLength, byteLength); + return initData; +} + +async function hmac(secret, value) { + const key = await cryptoApi.subtle.importKey( + "raw", + secret, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array(await cryptoApi.subtle.sign("HMAC", key, textEncoder.encode(value))); +} + +function bytesToHex(bytes) { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function assertRedacted(value, secrets) { + for (const secret of secrets) { + assert.equal(value.includes(secret), false, `response leaked ${secret}`); + } +} diff --git a/tests/telegram-oidc-worker.test.mjs b/tests/telegram-oidc-worker.test.mjs index 06f4559..9cc3e90 100644 --- a/tests/telegram-oidc-worker.test.mjs +++ b/tests/telegram-oidc-worker.test.mjs @@ -113,9 +113,59 @@ test("Telegram OIDC start requires configuration and strict platform JSON", asyn ); assert.equal(oversized.status, 400); assert.deepEqual(await oversized.json(), { error: "Invalid request" }); + + const strictBodyCases = [ + new Request("https://worker.test/auth/telegram/mobile/start", { + method: "POST", + headers: { "Content-Type": "application/json", "Content-Length": "2048" }, + body: JSON.stringify({ platform: "web" }), + }), + new Request("https://worker.test/auth/telegram/mobile/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + new Request("https://worker.test/auth/telegram/mobile/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "[]", + }), + ]; + for (const request of strictBodyCases) { + const response = await worker.fetch(request, env); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "Invalid request" }); + } assert.equal(db.queryOne("SELECT COUNT(*) AS count FROM telegram_oidc_flows").count, 0); }); +test("Telegram OIDC start and callback redact unavailable service state", async () => { + const storageSecret = "sensitive-d1-failure"; + const start = await postJson( + "/auth/telegram/mobile/start", + { platform: "web" }, + oidcEnv({ + prepare() { + throw new Error(storageSecret); + }, + }), + ); + const startBody = await start.text(); + assert.equal(start.status, 503); + assert.deepEqual(JSON.parse(startBody), { error: "Telegram OIDC unavailable" }); + assertRedacted(startBody, [storageSecret, clientSecret]); + + const state = "s".repeat(43); + const code = "sensitive-callback-code"; + const callback = await worker.fetch( + new Request(`https://worker.test/auth/telegram/mobile/callback?state=${state}&code=${code}`), + {}, + ); + const location = callback.headers.get("Location"); + assert.equal(callback.status, 302); + assert.equal(location, `${canonicalWebTarget}?auth_error=telegram`); + assertRedacted(location, [state, code]); +}); + test("Telegram OIDC start persists only the state hash and schedules bounded cleanup", async (t) => { const db = memoryD1(t); seedExpiredFlowsAndTickets(db, 102); @@ -237,6 +287,28 @@ test("Telegram OIDC callback rejects expired, missing, and malformed flow inputs const expired = await startFlow(db, "android"); db.execute("UPDATE telegram_oidc_flows SET expires_at = ?", epochSeconds() - 1); const sensitiveCode = "callback-secret-code"; + + const missingCode = await startFlow(db, "web"); + const missingCodeResponse = await worker.fetch( + new Request(`https://worker.test/auth/telegram/mobile/callback?state=${missingCode.state}`), + oidcEnv(db), + ); + assert.equal(missingCodeResponse.status, 302); + assert.equal(missingCodeResponse.headers.get("Location"), `${canonicalWebTarget}?auth_error=telegram`); + assertRedacted(missingCodeResponse.headers.get("Location"), [missingCode.state, clientSecret]); + + const oversizedCode = await startFlow(db, "ios"); + const oversizedCodeValue = "x".repeat(4_097); + const oversizedCodeResponse = await worker.fetch( + new Request( + `https://worker.test/auth/telegram/mobile/callback?state=${oversizedCode.state}&code=${oversizedCodeValue}`, + ), + oidcEnv(db), + ); + assert.equal(oversizedCodeResponse.status, 302); + assert.equal(oversizedCodeResponse.headers.get("Location"), "salvo://open/auth/error"); + assertRedacted(oversizedCodeResponse.headers.get("Location"), [oversizedCode.state, oversizedCodeValue, clientSecret]); + const cases = [ `/auth/telegram/mobile/callback?state=${expired.state}&code=${sensitiveCode}`, `/auth/telegram/mobile/callback?state=malformed.state&code=${sensitiveCode}`, @@ -253,6 +325,39 @@ test("Telegram OIDC callback rejects expired, missing, and malformed flow inputs assert.equal(db.queryOne("SELECT COUNT(*) AS count FROM telegram_login_tickets").count, 0); }); +test("Telegram OIDC redeem rejects unavailable storage and corrupt stored identities generically", async (t) => { + const ticket = base64Url(Uint8Array.from({ length: 32 }, (_, index) => index + 1)); + const unavailable = await postJson("/auth/telegram/mobile/redeem", { ticket }, {}); + const unavailableBody = await unavailable.text(); + assert.equal(unavailable.status, 401); + assert.deepEqual(JSON.parse(unavailableBody), { error: "Telegram authentication failed" }); + assertRedacted(unavailableBody, [ticket]); + + const db = memoryD1(t); + const identitySecret = "corrupt-stored-identity"; + const now = epochSeconds(); + db.execute( + `INSERT INTO telegram_login_tickets + (ticket_hash, user_json, created_at, expires_at, consumed_at) + VALUES (?, ?, ?, ?, NULL)`, + await sha256Base64Url(ticket), + JSON.stringify({ ...telegramUser(), id: "", identitySecret }), + now, + now + 300, + ); + + const corrupt = await postJson("/auth/telegram/mobile/redeem", { ticket }, oidcEnv(db)); + const corruptBody = await corrupt.text(); + assert.equal(corrupt.status, 401); + assert.deepEqual(JSON.parse(corruptBody), { error: "Telegram authentication failed" }); + assertRedacted(corruptBody, [ticket, identitySecret, clientSecret]); + assert.notEqual( + db.queryOne("SELECT consumed_at FROM telegram_login_tickets WHERE ticket_hash = ?", await sha256Base64Url(ticket)) + .consumed_at, + null, + ); +}); + test("Telegram OIDC redeem returns a hashed opaque session and consumes the ticket once", async (t) => { const db = memoryD1(t); const ticket = await successfulCallbackTicket(db, "web"); diff --git a/tests/telegram-oidc.test.mjs b/tests/telegram-oidc.test.mjs index cb099ea..bc0e752 100644 --- a/tests/telegram-oidc.test.mjs +++ b/tests/telegram-oidc.test.mjs @@ -339,6 +339,120 @@ test("exchangeTelegramCode rejects oversized token responses", async () => { ); }); +test("exchangeTelegramCode rejects declared oversized responses before reading the body", async () => { + let bodyRead = false; + + await assertTelegramFailure(() => + exchangeTelegramCode({ + code: "code", + redirectUri, + clientId, + clientSecret, + codeVerifier: "verifier", + fetcher: async () => ({ + ok: true, + headers: { + get(name) { + assert.equal(name, "Content-Length"); + return String(64 * 1024 + 1); + }, + }, + body: { + getReader() { + bodyRead = true; + throw new Error("oversized response body must not be read"); + }, + }, + }), + }), + ); + + assert.equal(bodyRead, false); +}); + +test("exchangeTelegramCode rejects non-byte response stream chunks", async () => { + let releasedLocks = 0; + + await assertTelegramFailure(() => + exchangeTelegramCode({ + code: "code", + redirectUri, + clientId, + clientSecret, + codeVerifier: "verifier", + fetcher: async () => ({ + ok: true, + headers: { get: () => null }, + body: { + getReader() { + return { + async read() { + return { done: false, value: "not bytes" }; + }, + releaseLock() { + releasedLocks += 1; + }, + }; + }, + }, + }), + }), + ); + + assert.equal(releasedLocks, 1); +}); + +test("exchangeTelegramCode supports bounded non-stream response bodies", async () => { + const payload = { id_token: "signed.id.token", token_type: "Bearer" }; + + assert.deepEqual( + await exchangeTelegramCode({ + code: "code", + redirectUri, + clientId, + clientSecret, + codeVerifier: "verifier", + fetcher: async () => ({ + status: 200, + headers: { get: () => null }, + body: null, + async text() { + return JSON.stringify(payload); + }, + }), + }), + payload, + ); +}); + +test("exchangeTelegramCode rejects invalid non-stream response contracts", async () => { + const fetchers = [ + async () => null, + async () => ({ ok: true, headers: { get: () => null }, body: null }), + async () => ({ + ok: true, + headers: { get: () => null }, + body: null, + async text() { + return JSON.stringify({ id_token: "provider-token", padding: "x".repeat(70 * 1024) }); + }, + }), + ]; + + for (const fetcher of fetchers) { + await assertTelegramFailure(() => + exchangeTelegramCode({ + code: "code", + redirectUri, + clientId, + clientSecret, + codeVerifier: "verifier", + fetcher, + }), + ); + } +}); + test("verifyTelegramIdToken verifies a real RS256 token and normalizes Telegram identity", async () => { let jwksLoads = 0; const idToken = await signJwt({ @@ -426,7 +540,12 @@ test("verifyTelegramIdToken rejects malformed or padded base64url before loading return { keys: [publicJwk] }; }); - for (const token of [`***.${payload}.AA`, `${header}=.${payload}.AA`, `${header}.${payload}.A`]) { + for (const token of [ + `***.${payload}.AA`, + `${header}=.${payload}.AA`, + `${header}.${payload}.A`, + `${header}.${payload}.AB`, + ]) { await assertTelegramFailure(() => verifyTelegramIdToken(token, options)); } assert.equal(jwksLoads, 0); @@ -505,6 +624,14 @@ test("verifyTelegramIdToken redacts JWKS loader errors", async () => { ); }); +test("verifyTelegramIdToken requires an explicit JWKS loader", async () => { + const token = await signJwt(); + + await assertTelegramFailure(() => + verifyTelegramIdToken(token, { clientId, nonce, now }), + ); +}); + test("verifyTelegramIdToken requires the exact Telegram issuer", async () => { for (const iss of ["https://oauth.telegram.org/", "http://oauth.telegram.org", "telegram"]) await assertTelegramFailure(() => verifyValidToken(validClaims({ iss }))); diff --git a/tests/telegram-platform.test.mjs b/tests/telegram-platform.test.mjs new file mode 100644 index 0000000..70bc9a9 --- /dev/null +++ b/tests/telegram-platform.test.mjs @@ -0,0 +1,1126 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createLocalBattleSnapshotStore } from "../src/core/local-battle-snapshot.js"; +import { createMobileRuntime } from "../src/mobile.js"; +import { createTelegramPlatform } from "../src/platform/telegram.js"; + +const buttonCleanupError = { + name: "Error", + message: "Telegram button cleanup failed", +}; + +const buttonVisibilityError = { + name: "Error", + message: "Telegram button visibility update failed", +}; + +const closingConfirmationError = { + name: "Error", + message: "Telegram closing confirmation update failed", +}; + +const eventCleanupError = { + name: "Error", + message: "Telegram event cleanup failed", +}; + +const settingsStorageError = { + name: "Error", + message: "Settings storage unavailable", +}; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function storageHarness() { + const calls = []; + const values = new Map(); + + return { + calls, + storage: { + getItem(key) { + calls.push(["get", key]); + return values.get(key) ?? null; + }, + setItem(key, value) { + calls.push(["set", key, value]); + values.set(key, value); + }, + removeItem(key) { + calls.push(["remove", key]); + values.delete(key); + }, + }, + }; +} + +function fakeTelegram({ versionAtLeast = true } = {}) { + const calls = []; + const events = new Map(); + const windowEvents = new Map(); + const css = new Map(); + + function addEvent(name, listener) { + const listeners = events.get(name) ?? new Set(); + listeners.add(listener); + events.set(name, listeners); + } + + function removeEvent(name, listener) { + events.get(name)?.delete(listener); + } + + function button(name) { + const listeners = new Set(); + return { + listeners, + api: { + onClick(listener) { + calls.push([name, "onClick"]); + listeners.add(listener); + }, + offClick(listener) { + calls.push([name, "offClick"]); + listeners.delete(listener); + }, + show() { + calls.push([name, "show"]); + }, + hide() { + calls.push([name, "hide"]); + }, + }, + }; + } + + const back = button("back"); + const settings = button("settings"); + const webApp = { + initData: "signed-init-data", + initDataUnsafe: { start_param: "room_ABCD" }, + colorScheme: "dark", + viewportHeight: 640, + viewportStableHeight: 620, + isExpanded: true, + safeAreaInset: { top: 10, right: 11, bottom: 12, left: 13 }, + contentSafeAreaInset: { top: 20, right: 21, bottom: 22, left: 23 }, + BackButton: back.api, + SettingsButton: settings.api, + HapticFeedback: { + impactOccurred(style) { + calls.push(["impact", style]); + }, + notificationOccurred(type) { + calls.push(["notification", type]); + }, + }, + isVersionAtLeast(version) { + calls.push(["version", version]); + return versionAtLeast; + }, + ready() { + calls.push(["ready"]); + }, + expand() { + calls.push(["expand"]); + }, + requestFullscreen() { + calls.push(["fullscreen"]); + }, + setHeaderColor(color) { + calls.push(["header", color]); + }, + setBackgroundColor(color) { + calls.push(["background", color]); + }, + enableClosingConfirmation() { + calls.push(["closing", true]); + }, + disableClosingConfirmation() { + calls.push(["closing", false]); + }, + openTelegramLink(url) { + calls.push(["telegram-link", url]); + }, + openLink(url) { + calls.push(["link", url]); + }, + onEvent(name, listener) { + calls.push(["onEvent", name]); + addEvent(name, listener); + }, + offEvent(name, listener) { + calls.push(["offEvent", name]); + removeEvent(name, listener); + }, + }; + const window = { + document: { + documentElement: { + style: { + setProperty(name, value) { + css.set(name, value); + }, + }, + }, + }, + addEventListener(name, listener) { + windowEvents.set(name, listener); + }, + removeEventListener(name, listener) { + if (windowEvents.get(name) === listener) windowEvents.delete(name); + }, + open(...args) { + calls.push(["window-open", ...args]); + }, + }; + + return { + back, + calls, + css, + events, + settings, + webApp, + window, + windowEvents, + emit(name, event) { + for (const listener of [...(events.get(name) ?? [])]) listener(event); + }, + }; +} + +test("Telegram adapter exposes launch and network contract", async () => { + const fake = fakeTelegram(); + const navigator = { onLine: true }; + const adapter = createTelegramPlatform({ + webApp: fake.webApp, + window: fake.window, + navigator, + storage: storageHarness().storage, + }); + + assert.equal(adapter.isNative(), false); + assert.equal(adapter.getPlatform(), "telegram"); + assert.equal(adapter.isAvailable(), true); + assert.equal(adapter.getLaunchData(), "signed-init-data"); + assert.equal(adapter.getStartParam(), "room_ABCD"); + assert.deepEqual(await adapter.getNetworkStatus(), { + connected: true, + connectionType: "unknown", + }); + + const changes = []; + const remove = await adapter.onNetworkChange((status) => changes.push(status)); + navigator.onLine = false; + fake.windowEvents.get("offline")(); + navigator.onLine = true; + fake.windowEvents.get("online")(); + assert.deepEqual(changes, [ + { connected: false, connectionType: "none" }, + { connected: true, connectionType: "unknown" }, + ]); + remove(); + assert.equal(fake.windowEvents.size, 0); +}); + +test("Telegram BackButton visibility is explicit while SettingsButton stays visible", async () => { + const fake = fakeTelegram(); + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + let backs = 0; + let settings = 0; + + const removeBack = await adapter.onBack(() => { + backs += 1; + }); + const removeSettings = await adapter.onSettings(() => { + settings += 1; + }); + assert.equal(fake.calls.some((call) => call[0] === "back" && call[1] === "show"), false); + assert.equal(fake.calls.filter((call) => call[0] === "settings" && call[1] === "show").length, 1); + for (const listener of fake.back.listeners) listener(); + for (const listener of fake.settings.listeners) listener(); + assert.equal(backs, 1); + assert.equal(settings, 1); + + await adapter.setBackButtonVisible(true); + await adapter.setBackButtonVisible(false); + assert.equal(fake.back.listeners.size, 1, "hiding does not remove the listener"); + for (const listener of fake.back.listeners) listener(); + assert.equal(backs, 2); + await adapter.setBackButtonVisible(true); + + await removeBack(); + await removeSettings(); + assert.equal(fake.back.listeners.size, 0); + assert.equal(fake.settings.listeners.size, 0); + assert.deepEqual(fake.calls.filter(([scope]) => scope === "back"), [ + ["back", "onClick"], + ["back", "show"], + ["back", "hide"], + ["back", "show"], + ["back", "offClick"], + ["back", "hide"], + ]); + assert.deepEqual(fake.calls.filter(([scope]) => scope === "settings"), [ + ["settings", "onClick"], + ["settings", "show"], + ["settings", "offClick"], + ["settings", "hide"], + ]); +}); + +test("Telegram awaits rejected event and button registrations", async () => { + const fake = fakeTelegram(); + const eventRegistration = deferred(); + const buttonRegistration = deferred(); + fake.webApp.onEvent = () => eventRegistration.promise; + fake.back.api.onClick = () => buttonRegistration.promise; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + let eventSettled = false; + let buttonSettled = false; + + const eventSetup = adapter.onThemeChange(() => {}); + const buttonSetup = adapter.onBack(() => {}); + void eventSetup.then(() => { + eventSettled = true; + }); + void buttonSetup.then(() => { + buttonSettled = true; + }); + await Promise.resolve(); + assert.equal(eventSettled, false); + assert.equal(buttonSettled, false); + assert.equal(fake.calls.some((call) => call[0] === "back" && call[1] === "show"), false); + + eventRegistration.reject(new Error("private event registration failure")); + buttonRegistration.reject(new Error("private button registration failure")); + const removeEvent = await eventSetup; + const removeButton = await buttonSetup; + await assert.doesNotReject(() => removeEvent()); + await assert.doesNotReject(() => removeButton()); + assert.equal(fake.back.listeners.size, 0); + assert.equal(fake.calls.some((call) => call[0] === "back" && call[1] === "show"), false); + assert.equal(fake.calls.some((call) => call[0] === "offEvent"), false); +}); + +test("Telegram rolls back a partial multi-event registration failure", async () => { + const fake = fakeTelegram(); + const originalOnEvent = fake.webApp.onEvent; + fake.webApp.onEvent = function onEvent(name, listener) { + if (name === "deactivated") { + return Promise.reject(new Error("private second registration failure")); + } + return originalOnEvent.call(this, name, listener); + }; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + const lifecycle = []; + + const remove = await adapter.onLifecycleChange((state) => lifecycle.push(state)); + assert.equal(fake.events.get("activated").size, 0); + assert.equal(fake.events.has("deactivated"), false); + fake.emit("activated"); + assert.deepEqual(lifecycle, []); + await assert.doesNotReject(() => remove()); + assert.equal(fake.calls.filter((call) => ( + call[0] === "offEvent" && call[1] === "activated" + )).length, 1); +}); + +test("Telegram button subscriptions are reference-counted in both removal orders", async () => { + const fake = fakeTelegram(); + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + await adapter.setBackButtonVisible(true); + const removeBackFirst = await adapter.onBack(() => {}); + const removeBackSecond = await adapter.onBack(() => {}); + const removeSettingsFirst = await adapter.onSettings(() => {}); + const removeSettingsSecond = await adapter.onSettings(() => {}); + + assert.equal(fake.back.listeners.size, 2); + assert.equal(fake.settings.listeners.size, 2); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "show").length, 1); + assert.equal(fake.calls.filter((call) => call[0] === "settings" && call[1] === "show").length, 1); + + const backFirst = removeBackFirst(); + assert.equal(removeBackFirst(), backFirst); + await backFirst; + assert.equal(fake.back.listeners.size, 1); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "hide").length, 0); + const backSecond = removeBackSecond(); + assert.equal(removeBackSecond(), backSecond); + await backSecond; + assert.equal(fake.back.listeners.size, 0); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "hide").length, 1); + assert.equal(removeBackFirst(), backFirst); + assert.equal(removeBackSecond(), backSecond); + + const settingsSecond = removeSettingsSecond(); + assert.equal(removeSettingsSecond(), settingsSecond); + await settingsSecond; + assert.equal(fake.settings.listeners.size, 1); + assert.equal(fake.calls.filter((call) => call[0] === "settings" && call[1] === "hide").length, 0); + const settingsFirst = removeSettingsFirst(); + assert.equal(removeSettingsFirst(), settingsFirst); + await settingsFirst; + assert.equal(fake.settings.listeners.size, 0); + assert.equal(fake.calls.filter((call) => call[0] === "settings" && call[1] === "hide").length, 1); +}); + +test("Telegram serializes a subscription arriving during button hide", async () => { + const fake = fakeTelegram(); + const hideStarted = deferred(); + const releaseHide = deferred(); + fake.back.api.hide = async () => { + fake.calls.push(["back", "hide"]); + hideStarted.resolve(); + await releaseHide.promise; + }; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + await adapter.setBackButtonVisible(true); + const removeFirst = await adapter.onBack(() => {}); + + const removingFirst = removeFirst(); + await hideStarted.promise; + let secondSettled = false; + const secondSetup = adapter.onBack(() => {}); + void secondSetup.then(() => { + secondSettled = true; + }); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + + assert.equal(secondSettled, false); + assert.equal(fake.back.listeners.size, 1); + releaseHide.resolve(); + const removeSecond = await secondSetup; + await removingFirst; + + assert.equal(fake.back.listeners.size, 1); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "hide").length, 1); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "show").length, 2); + await removeSecond(); +}); + +test("Telegram retries rejected button hide without repeating offClick", async () => { + const fake = fakeTelegram(); + const originalHide = fake.back.api.hide; + let hideAttempts = 0; + fake.back.api.hide = function hide() { + hideAttempts += 1; + if (hideAttempts === 1) { + return Promise.reject(new Error("private button hide failure")); + } + return originalHide.call(this); + }; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + await adapter.setBackButtonVisible(true); + const remove = await adapter.onBack(() => {}); + + const failed = remove(); + assert.equal(remove(), failed); + await assert.rejects(failed, buttonCleanupError); + assert.equal(fake.back.listeners.size, 0); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "offClick").length, 1); + + const retry = remove(); + assert.notEqual(retry, failed); + assert.equal(remove(), retry); + await retry; + assert.equal(hideAttempts, 2); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "offClick").length, 1); + assert.equal(remove(), retry); +}); + +test("Telegram button cleanup contains failures and retries removal", async () => { + const fake = fakeTelegram(); + const originalOffClick = fake.back.api.offClick; + let attempts = 0; + fake.back.api.offClick = function offClick(listener) { + attempts += 1; + if (attempts === 1) { + return Promise.reject(new Error("private button removal failure")); + } + return originalOffClick.call(this, listener); + }; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + await adapter.setBackButtonVisible(true); + const remove = await adapter.onBack(() => {}); + + const failed = remove(); + assert.equal(remove(), failed); + await assert.rejects(failed, buttonCleanupError); + assert.equal(fake.back.listeners.size, 1); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "hide").length, 0); + + const retry = remove(); + assert.notEqual(retry, failed); + assert.equal(remove(), retry); + await retry; + assert.equal(attempts, 2); + assert.equal(fake.back.listeners.size, 0); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "hide").length, 1); + assert.equal(remove(), retry); +}); + +test("Telegram BackButton visibility failures are redacted and retryable", async () => { + const fake = fakeTelegram(); + const originalShow = fake.back.api.show; + let showAttempts = 0; + fake.back.api.show = function show() { + showAttempts += 1; + if (showAttempts === 1) { + return Promise.reject(new Error("private BackButton provider detail")); + } + return originalShow.call(this); + }; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + const remove = await adapter.onBack(() => {}); + + await assert.rejects(adapter.setBackButtonVisible(true), buttonVisibilityError); + assert.equal(showAttempts, 1); + await assert.doesNotReject(() => adapter.setBackButtonVisible(true)); + assert.equal(showAttempts, 2); + + await remove(); +}); + +test("Telegram retries visibility requested before BackButton subscription", async () => { + const fake = fakeTelegram(); + const originalShow = fake.back.api.show; + let showAttempts = 0; + fake.back.api.show = function show() { + showAttempts += 1; + if (showAttempts === 1) { + return Promise.reject(new Error("private deferred show failure")); + } + return originalShow.call(this); + }; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + + await adapter.setBackButtonVisible(true); + const remove = await adapter.onBack(() => {}); + assert.equal(fake.back.listeners.size, 1); + assert.equal(showAttempts, 1); + await assert.doesNotReject(() => adapter.setBackButtonVisible(true)); + assert.equal(showAttempts, 2); + + await remove(); + assert.equal(fake.back.listeners.size, 0); +}); + +test("Telegram ready retries SettingsButton visibility after its first show fails", async () => { + const fake = fakeTelegram(); + const originalShow = fake.settings.api.show; + let showAttempts = 0; + fake.settings.api.show = function show() { + showAttempts += 1; + if (showAttempts === 1) { + return Promise.reject(new Error("private SettingsButton provider detail")); + } + return originalShow.call(this); + }; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + let settings = 0; + const remove = await adapter.onSettings(() => { + settings += 1; + }); + + assert.equal(fake.settings.listeners.size, 1); + assert.equal(showAttempts, 1); + for (const listener of fake.settings.listeners) listener(); + assert.equal(settings, 1, "show failure must not discard the registered callback"); + + await adapter.ready(); + assert.equal(showAttempts, 2); + + await remove(); + assert.equal(fake.settings.listeners.size, 0); + assert.equal( + fake.calls.filter((call) => call[0] === "settings" && call[1] === "hide").length, + 1, + ); +}); + +test("Telegram ready contains persistent SettingsButton visibility failures", async () => { + const fake = fakeTelegram(); + let showAttempts = 0; + fake.settings.api.show = function show() { + showAttempts += 1; + return Promise.reject(new Error("private persistent SettingsButton detail")); + }; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + let settings = 0; + const remove = await adapter.onSettings(() => { + settings += 1; + }); + + assert.equal(fake.settings.listeners.size, 1); + assert.equal(showAttempts, 1); + await assert.doesNotReject(() => adapter.ready()); + assert.equal(showAttempts, 2); + for (const listener of fake.settings.listeners) listener(); + assert.equal(settings, 1); + + await remove(); + assert.equal(fake.settings.listeners.size, 0); + assert.equal( + fake.calls.filter((call) => call[0] === "settings" && call[1] === "hide").length, + 1, + ); +}); + +test("mobile runtime keeps Telegram BackButton subscribed while home is hidden", async () => { + const fake = fakeTelegram(); + const platform = createTelegramPlatform({ + webApp: fake.webApp, + window: fake.window, + navigator: { onLine: true }, + }); + const runtime = createMobileRuntime({ + platform, + snapshots: { load: async () => null, save: async () => {} }, + getState: () => ({}), + applySnapshot: async () => {}, + onRestoreError: async () => {}, + onNetwork: async () => {}, + onDeepLink: async () => {}, + onBack: async () => true, + pauseAudio: async () => {}, + resumeAudio: async () => {}, + onRuntimeError: async () => {}, + }); + + await runtime.start(); + assert.equal(fake.back.listeners.size, 1); + assert.equal(fake.calls.some((call) => call[0] === "back" && call[1] === "show"), false); + await platform.setBackButtonVisible(true); + assert.equal(fake.back.listeners.size, 1); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "show").length, 1); + + await runtime.stop(); + assert.equal(fake.back.listeners.size, 0); + assert.equal(fake.calls.filter((call) => call[0] === "back" && call[1] === "hide").length, 1); +}); + +test("Telegram event cleanup contains failures and retries removal", async () => { + const fake = fakeTelegram(); + const originalOffEvent = fake.webApp.offEvent; + let attempts = 0; + fake.webApp.offEvent = function offEvent(name, listener) { + attempts += 1; + if (attempts === 1) { + return Promise.reject(new Error("private event removal failure")); + } + return originalOffEvent.call(this, name, listener); + }; + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + const remove = await adapter.onThemeChange(() => {}); + + const failed = remove(); + assert.equal(remove(), failed); + await assert.rejects(failed, eventCleanupError); + assert.equal(fake.events.get("themeChanged").size, 1); + + const retry = remove(); + assert.notEqual(retry, failed); + assert.equal(remove(), retry); + await retry; + assert.equal(attempts, 2); + assert.equal(fake.events.get("themeChanged").size, 0); + assert.equal(remove(), retry); +}); + +test("mobile runtime retries failed Telegram lifecycle cleanup before restart", async () => { + const fake = fakeTelegram(); + const originalOffEvent = fake.webApp.offEvent; + let activatedRemovalAttempts = 0; + fake.webApp.offEvent = function offEvent(name, listener) { + if (name === "activated") { + activatedRemovalAttempts += 1; + if (activatedRemovalAttempts === 1) { + return Promise.reject(new Error("private lifecycle removal failure")); + } + } + return originalOffEvent.call(this, name, listener); + }; + const platform = createTelegramPlatform({ + webApp: fake.webApp, + window: fake.window, + navigator: { onLine: true }, + }); + let activatedDeliveries = 0; + const runtime = createMobileRuntime({ + platform, + snapshots: { + load: async () => null, + save: async () => {}, + }, + getState: () => ({}), + applySnapshot: async () => {}, + onRestoreError: async () => {}, + onNetwork: async () => {}, + onDeepLink: async () => {}, + onBack: async () => false, + pauseAudio: async () => {}, + resumeAudio: async () => { + activatedDeliveries += 1; + }, + onRuntimeError: async () => {}, + }); + + await runtime.start(); + await assert.rejects(runtime.stop(), (error) => { + assert.ok(error instanceof AggregateError); + assert.deepEqual(error.errors.map(({ name, message }) => ({ name, message })), [ + eventCleanupError, + ]); + return true; + }); + assert.equal(fake.events.get("activated").size, 1); + + await runtime.start(); + assert.equal(activatedRemovalAttempts, 2); + assert.equal(fake.events.get("activated").size, 1); + fake.emit("activated"); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + assert.equal(activatedDeliveries, 1); + + await runtime.stop(); + assert.equal(fake.events.get("activated").size, 0); +}); + +test("Telegram lifecycle and theme events map and remove listeners", async () => { + const fake = fakeTelegram(); + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + const lifecycle = []; + const themes = []; + const removeLifecycle = await adapter.onLifecycleChange((state) => lifecycle.push(state)); + const removeTheme = await adapter.onThemeChange((theme) => themes.push(theme)); + + assert.equal(adapter.getTheme(), "dark"); + fake.emit("deactivated"); + fake.emit("activated"); + fake.webApp.colorScheme = "light"; + fake.emit("themeChanged"); + assert.deepEqual(lifecycle, [{ active: false }, { active: true }]); + assert.deepEqual(themes, ["light"]); + + await removeLifecycle(); + await removeTheme(); + assert.equal(fake.events.get("activated").size, 0); + assert.equal(fake.events.get("deactivated").size, 0); + assert.equal(fake.events.get("themeChanged").size, 0); +}); + +test("Telegram gates lifecycle events below version 8.0", async () => { + const fake = fakeTelegram({ versionAtLeast: false }); + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + const lifecycle = []; + + const remove = await adapter.onLifecycleChange((state) => lifecycle.push(state)); + assert.ok(fake.calls.some((call) => call[0] === "version" && call[1] === "8.0")); + assert.equal(fake.events.has("activated"), false); + assert.equal(fake.events.has("deactivated"), false); + fake.emit("activated"); + fake.emit("deactivated"); + assert.deepEqual(lifecycle, []); + await assert.doesNotReject(() => remove()); +}); + +test("Telegram viewport events expose state and update safe-area CSS variables", async () => { + const fake = fakeTelegram(); + const providerReads = []; + const webApp = new Proxy(fake.webApp, { + get(target, property, receiver) { + providerReads.push(property); + return Reflect.get(target, property, receiver); + }, + }); + const adapter = createTelegramPlatform({ webApp, window: fake.window }); + const viewports = []; + const remove = await adapter.onViewportChange((viewport) => viewports.push(viewport)); + + assert.equal(fake.events.get("viewportChanged").size, 1); + assert.equal(fake.events.get("safeAreaChanged").size, 1); + assert.equal(fake.events.get("contentSafeAreaChanged").size, 1); + assert.equal(fake.css.get("--tg-viewport-height"), "640px"); + assert.equal(fake.css.get("--tg-viewport-stable-height"), "620px"); + assert.equal(fake.css.get("--tg-safe-area-inset-top"), "10px"); + assert.equal(fake.css.get("--tg-safe-area-inset-right"), "11px"); + assert.equal(fake.css.get("--tg-safe-area-inset-bottom"), "12px"); + assert.equal(fake.css.get("--tg-safe-area-inset-left"), "13px"); + assert.equal(fake.css.get("--tg-content-safe-area-inset-top"), "20px"); + assert.equal(fake.css.get("--tg-content-safe-area-inset-bottom"), "22px"); + assert.equal(providerReads.includes("isViewportStable"), false); + + fake.emit("safeAreaChanged"); + assert.equal(viewports.at(-1).isStateStable, false); + + fake.webApp.viewportHeight = 600; + fake.webApp.viewportStableHeight = 590; + fake.webApp.contentSafeAreaInset = { top: 30, right: 31, bottom: 32, left: 33 }; + fake.emit("viewportChanged", { isStateStable: true }); + fake.emit("contentSafeAreaChanged"); + assert.equal(fake.css.get("--tg-viewport-height"), "600px"); + assert.equal(fake.css.get("--tg-content-safe-area-inset-bottom"), "32px"); + assert.deepEqual(viewports.at(-1), { + height: 600, + stableHeight: 590, + isExpanded: true, + isStateStable: true, + safeAreaInset: { top: 10, right: 11, bottom: 12, left: 13 }, + contentSafeAreaInset: { top: 30, right: 31, bottom: 32, left: 33 }, + }); + + await remove(); + for (const name of [ + "viewportChanged", + "safeAreaChanged", + "contentSafeAreaChanged", + ]) { + assert.equal(fake.events.get(name).size, 0); + } +}); + +test("Telegram ready expands, colors, and requests gated fullscreen", async () => { + const fake = fakeTelegram(); + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + + await adapter.ready(); + await adapter.setClosingConfirmation(true); + await adapter.setClosingConfirmation(false); + + assert.deepEqual(fake.calls.filter(([name]) => [ + "ready", + "expand", + "header", + "background", + "fullscreen", + "closing", + ].includes(name)), [ + ["ready"], + ["expand"], + ["header", "#07111f"], + ["background", "#07111f"], + ["fullscreen"], + ["closing", true], + ["closing", false], + ]); + assert.ok(fake.calls.some((call) => call[0] === "version" && call[1] === "8.0")); +}); + +test("Telegram closing confirmation failures are redacted and reject", async () => { + const fake = fakeTelegram(); + fake.webApp.enableClosingConfirmation = () => ( + Promise.reject(new Error("private closing-confirmation provider detail")) + ); + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + + await assert.rejects( + adapter.setClosingConfirmation(true), + closingConfirmationError, + ); + await assert.doesNotReject(() => adapter.setClosingConfirmation(false)); +}); + +test("Telegram gates fullscreen and safe-area APIs below version 8.0", async () => { + const fake = fakeTelegram({ versionAtLeast: false }); + let safeAreaReads = 0; + Object.defineProperties(fake.webApp, { + safeAreaInset: { + configurable: true, + get() { + safeAreaReads += 1; + return { top: 1, right: 1, bottom: 1, left: 1 }; + }, + }, + contentSafeAreaInset: { + configurable: true, + get() { + safeAreaReads += 1; + return { top: 1, right: 1, bottom: 1, left: 1 }; + }, + }, + }); + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + + await adapter.ready(); + const remove = await adapter.onViewportChange(() => {}); + assert.equal(fake.calls.some(([name]) => name === "fullscreen"), false); + assert.equal(fake.events.has("safeAreaChanged"), false); + assert.equal(fake.events.has("contentSafeAreaChanged"), false); + assert.equal(safeAreaReads, 0); + assert.equal(fake.css.has("--tg-safe-area-inset-top"), false); + assert.equal(fake.css.get("--tg-viewport-height"), "640px"); + await remove(); +}); + +test("Telegram maps semantic haptics and ignores unsupported feedback", async () => { + const fake = fakeTelegram(); + const adapter = createTelegramPlatform({ webApp: fake.webApp, window: fake.window }); + + for (const event of [ + "placement", + "hit", + "sunk", + "invalid", + "victory", + "defeat", + "unknown", + ]) { + await adapter.haptic(event); + } + assert.deepEqual(fake.calls.filter(([name]) => ["impact", "notification"].includes(name)), [ + ["impact", "light"], + ["impact", "medium"], + ["impact", "heavy"], + ["notification", "warning"], + ["notification", "success"], + ["notification", "error"], + ]); + + fake.webApp.HapticFeedback.impactOccurred = () => { + throw new Error("unsupported"); + }; + await assert.doesNotReject(() => adapter.haptic("placement")); +}); + +test("Telegram shares natively, copies on failure, and routes external links", async () => { + const fake = fakeTelegram(); + const clipboard = []; + const navigator = { + clipboard: { writeText: async (value) => clipboard.push(value) }, + }; + const adapter = createTelegramPlatform({ + webApp: fake.webApp, + window: fake.window, + navigator, + }); + const payload = { + title: "Salvo", + text: "Join my battle", + url: "https://t.me/agents_salvo_bot?startapp=room_ABCD", + }; + + assert.deepEqual(await adapter.share(payload), { shared: true, copied: false }); + const shareCall = fake.calls.find(([name, url]) => ( + name === "telegram-link" && new URL(url).pathname === "/share/url" + )); + const shareUrl = new URL(shareCall[1]); + assert.equal(shareUrl.searchParams.get("url"), payload.url); + assert.equal(shareUrl.searchParams.get("text"), payload.text); + + await adapter.openExternalUrl("https://t.me/agents_salvo_bot"); + await adapter.openExternalUrl("https://telegram.me/agents_salvo_bot?startapp=room_ABCD"); + await adapter.openExternalUrl("https://salvo.test/privacy"); + assert.ok(fake.calls.some((call) => ( + call[0] === "telegram-link" && call[1] === "https://t.me/agents_salvo_bot" + ))); + assert.ok(fake.calls.some((call) => ( + call[0] === "link" && call[1] === "https://salvo.test/privacy" + ))); + assert.ok(fake.calls.some((call) => ( + call[0] === "telegram-link" + && call[1] === "https://t.me/agents_salvo_bot?startapp=room_ABCD" + ))); + + fake.webApp.openTelegramLink = () => { + throw new Error("unsupported"); + }; + assert.deepEqual(await adapter.share(payload), { shared: false, copied: true }); + assert.deepEqual(clipboard, [payload.url]); + navigator.clipboard.writeText = async () => Promise.reject(new Error("denied")); + assert.deepEqual(await adapter.share(payload), { shared: false, copied: false }); +}); + +test("Telegram preferences are prefixed while secure sessions stay in memory", async () => { + const fake = fakeTelegram(); + const storage = storageHarness(); + const adapter = createTelegramPlatform({ + webApp: fake.webApp, + window: fake.window, + storage: storage.storage, + }); + + await adapter.settings.set("theme", "dark"); + assert.equal(await adapter.settings.get("theme"), "dark"); + await adapter.settings.set("theme", null); + assert.equal(await adapter.settings.get("theme"), null); + await adapter.secureSession.set("telegram-token"); + assert.equal(await adapter.secureSession.get(), "telegram-token"); + await adapter.secureSession.clear(); + assert.equal(await adapter.secureSession.get(), ""); + assert.deepEqual(storage.calls, [ + ["set", "salvo.theme", "dark"], + ["get", "salvo.theme"], + ["remove", "salvo.theme"], + ["get", "salvo.theme"], + ]); + + const nextLaunch = createTelegramPlatform({ + webApp: fake.webApp, + window: fake.window, + storage: storage.storage, + }); + assert.equal(await nextLaunch.secureSession.get(), ""); +}); + +test("Telegram settings reject blocked and quota storage with a stable error", async () => { + const fake = fakeTelegram(); + const adapter = createTelegramPlatform({ + webApp: fake.webApp, + window: fake.window, + storage: { + getItem() { + throw new Error("private blocked-storage detail"); + }, + setItem() { + return Promise.reject(new DOMException( + "private quota detail", + "QuotaExceededError", + )); + }, + removeItem() { + throw new Error("private removal detail"); + }, + }, + }); + + await assert.rejects(adapter.settings.get("theme"), settingsStorageError); + await assert.rejects(adapter.settings.set("theme", "dark"), settingsStorageError); + await assert.rejects(adapter.settings.set("theme", null), settingsStorageError); + + const snapshots = createLocalBattleSnapshotStore(adapter.settings); + await assert.rejects(snapshots.load(), settingsStorageError); + await assert.rejects(snapshots.clear(), settingsStorageError); +}); + +test("Telegram creation tolerates inaccessible global localStorage", async (t) => { + const previous = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + t.after(() => { + if (previous) { + Object.defineProperty(globalThis, "localStorage", previous); + } else { + delete globalThis.localStorage; + } + }); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + get() { + throw new Error("storage blocked"); + }, + }); + + let adapter; + assert.doesNotThrow(() => { + adapter = createTelegramPlatform({ webApp: {} }); + }); + await assert.rejects(adapter.settings.get("theme"), settingsStorageError); + await assert.rejects(adapter.settings.set("theme", "dark"), settingsStorageError); + await assert.rejects(adapter.settings.set("theme", null), settingsStorageError); +}); + +test("Telegram unavailable and throwing provider APIs remain safe", async () => { + const unavailable = createTelegramPlatform({ + webApp: undefined, + window: undefined, + navigator: undefined, + storage: undefined, + }); + assert.equal(unavailable.isAvailable(), false); + assert.equal(unavailable.getLaunchData(), ""); + assert.equal(unavailable.getStartParam(), ""); + assert.equal(unavailable.getTheme(), null); + assert.deepEqual(await unavailable.getNetworkStatus(), { + connected: true, + connectionType: "unknown", + }); + + const failure = () => { + throw new Error("provider failure"); + }; + const throwing = createTelegramPlatform({ + webApp: { + get initData() { return failure(); }, + get initDataUnsafe() { return failure(); }, + get colorScheme() { return failure(); }, + get BackButton() { return failure(); }, + get SettingsButton() { return failure(); }, + get HapticFeedback() { return failure(); }, + isVersionAtLeast: failure, + ready: failure, + expand: failure, + requestFullscreen: failure, + setHeaderColor: failure, + setBackgroundColor: failure, + enableClosingConfirmation: failure, + disableClosingConfirmation: failure, + openTelegramLink: failure, + openLink: failure, + onEvent: failure, + offEvent: failure, + }, + window: { + addEventListener: failure, + removeEventListener: failure, + open: failure, + document: { documentElement: { style: { setProperty: failure } } }, + }, + navigator: { + get onLine() { return failure(); }, + get clipboard() { return failure(); }, + }, + storage: { + getItem: failure, + setItem: failure, + removeItem: failure, + }, + }); + + assert.equal(throwing.isAvailable(), false); + assert.equal(throwing.getLaunchData(), ""); + assert.equal(throwing.getStartParam(), ""); + assert.equal(throwing.getTheme(), null); + assert.doesNotThrow(() => throwing.getNetworkStatus()); + await assert.doesNotReject(() => throwing.ready()); + await assert.doesNotReject(() => throwing.setBackButtonVisible(true)); + await assert.doesNotReject(() => throwing.setBackButtonVisible(false)); + await assert.rejects( + throwing.setClosingConfirmation(true), + closingConfirmationError, + ); + await assert.rejects( + throwing.setClosingConfirmation(false), + closingConfirmationError, + ); + await assert.doesNotReject(() => throwing.haptic("hit")); + await assert.doesNotReject(() => throwing.openExternalUrl("https://t.me/test")); + await assert.doesNotReject(() => throwing.openExternalUrl("https://example.com")); + assert.deepEqual(await throwing.share({ text: "x", url: "https://t.me/test" }), { + shared: false, + copied: false, + }); + await assert.rejects(throwing.settings.get("theme"), settingsStorageError); + await assert.rejects(throwing.settings.set("theme", "dark"), settingsStorageError); + await assert.rejects(throwing.settings.set("theme", null), settingsStorageError); + + for (const subscribe of [ + throwing.onNetworkChange, + throwing.onDeepLink, + throwing.onBack, + throwing.onSettings, + throwing.onLifecycleChange, + throwing.onThemeChange, + throwing.onViewportChange, + ]) { + const remove = await subscribe(() => {}); + await assert.doesNotReject(async () => remove()); + } +}); diff --git a/tests/ux-redesign.test.mjs b/tests/ux-redesign.test.mjs index bac4d78..3662fff 100644 --- a/tests/ux-redesign.test.mjs +++ b/tests/ux-redesign.test.mjs @@ -27,6 +27,32 @@ test("coverage uses separate truthful core and actual-app gates", () => { assert.match(scripts["coverage:app"], /tests\/app-behavior\.test\.mjs/); }); +test("coverage enforces critical files with explicit focused gates", () => { + const scripts = packageJson.scripts; + assert.equal( + scripts.coverage, + [ + "npm run coverage:core", + "npm run coverage:app", + "npm run coverage:critical:build-publication", + "npm run coverage:critical:build", + "npm run coverage:critical:worker", + ].join(" && "), + ); + assert.equal( + scripts["coverage:critical:build-publication"], + "SALVO_APP_CHILD_COVERAGE=isolated node --experimental-test-coverage --test-coverage-include=scripts/build-publication.mjs --test-coverage-lines=98 --test tests/telegram-build.test.mjs", + ); + assert.equal( + scripts["coverage:critical:build"], + "SALVO_APP_CHILD_COVERAGE=isolated node --experimental-test-coverage --test-coverage-include=scripts/build.mjs --test-coverage-lines=98 --test tests/telegram-build.test.mjs", + ); + assert.equal( + scripts["coverage:critical:worker"], + "node --experimental-test-coverage --test-coverage-include=worker/index.js --test-coverage-lines=98 --test tests/worker.test.mjs tests/profile.test.mjs tests/telegram-mini-app-worker.test.mjs tests/telegram-oidc-worker.test.mjs", + ); +}); + test("main menu is a focused game hub with agent play as the primary action", () => { assert.match(app, /class="game-hub"/); assert.match(app, /class="hub-primary"/); @@ -344,8 +370,9 @@ test("result modal can copy and share a battle summary through the platform", () assert.match(app, /platform\.openExternalUrl\(telegramUrl\.toString\(\)\)/); assert.match(app, /resultCopyStatus:\s*""/); assert.match(app, /state\.resultCopyStatus = "copied"/); - assert.match(app, /state\.resultCopyStatus = shared \? "" : "share-failed"/); + assert.match(app, /state\.resultCopyStatus = outcome\.shared \? "" : outcome\.copied \? "link-copied" : "share-failed"/); assert.match(app, /class="result-share-status status-line"/); + assert.match(i18n, /"share\.linkCopied"/); assert.match(i18n, /"result\.copySummary"/); assert.match(i18n, /"result\.copySuccess"/); assert.match(i18n, /"result\.shareSummary"/); @@ -744,14 +771,31 @@ test("network state renders an offline banner and guards remote work", () => { assert.ok(offlineGuard >= 0 && offlineGuard < scriptRequest, "Telegram widget must stop before its offline script request"); }); -test("native back navigation uses ordered overlays and a destructive-leave dialog", () => { +test("platform back navigation uses ordered dialogs, overlays, and destructive leave", () => { const back = sourceBetween("async function handlePlatformBack", "async function requestLeaveBattle"); + const leaveDialog = back.indexOf("state.leaveBattleDialog"); + const resultDialog = back.indexOf("isResultModalVisible()"); const settings = back.indexOf("state.settingsOpen"); const profile = back.indexOf("state.profileOpen"); const leaderboard = back.indexOf("state.leaderboardOpen"); - const detail = back.indexOf('["archive", "replay"]'); + const coaching = back.indexOf("isTacticalAdvisorVisible()"); + const replay = back.indexOf('state.screen === "replay"'); + const archive = back.indexOf('state.screen === "archive"'); const battle = back.indexOf('["setup", "playing", "pass", "training", "online"]'); - assert.ok(settings >= 0 && settings < profile && profile < leaderboard && leaderboard < detail && detail < battle); + assert.ok( + leaveDialog >= 0 + && leaveDialog < resultDialog + && resultDialog < settings + && settings < profile + && profile < leaderboard + && leaderboard < coaching + && coaching < replay + && replay < archive + && archive < battle, + ); + assert.match(back, /cancelLeaveBattle\(\)/); + assert.match(back, /closeResultModal\(\)/); + assert.match(back, /backToReplayArchive\(\)/); assert.match(back, /return false/); assert.match(app, /leaveBattleDialog:\s*false/); assert.match(app, /function renderLeaveBattleDialog/); @@ -814,10 +858,19 @@ test("room and summary sharing await platform share with Telegram fallback", () assert.match(app, /async function shareRoom/); assert.match(app, /async function shareBattleSummary/); assert.match(app, /const result = await platform\.share\(\{/); - assert.match(app, /if \(result\.shared\) return true/); + assert.match(app, /if \(result\.shared\) return \{ shared: true, copied: false \}/); + assert.match(app, /if \(result\.copied\) return \{ shared: false, copied: true \}/); assert.match(app, /await platform\.openExternalUrl\(telegramUrl\.toString\(\)\)/); - assert.match(app, /translate\("share\.failed"\)/); - assert.match(app, /state\.online\.error = shared \? "" : translate\("share\.failed"\)/); + assert.match(app, /translate\(failed \? "share\.failed" : "online\.inviteCopied"\)/); + assert.match( + app, + /state\.online\.shareStatus = outcome\.shared\s*\? ""\s*:\s*outcome\.copied \? "invite-copied" : "share-failed"/, + ); + assert.doesNotMatch( + sourceBetween("async function shareRoom", "async function shareWithTelegramFallback"), + /state\.online\.(?:status|error)/, + ); + assert.match(i18n, /"online\.inviteCopied"/); assert.match(app, /if \(action === "share-battle-summary"\) await shareBattleSummary\(\)/); assert.match(app, /if \(action === "share-telegram"\) await shareRoom\(\)/); }); diff --git a/tests/worker.test.mjs b/tests/worker.test.mjs index 0ee581c..627dbf2 100644 --- a/tests/worker.test.mjs +++ b/tests/worker.test.mjs @@ -238,6 +238,13 @@ test("worker handles CORS, not found, and room routing", async () => { assert.equal(notFoundResponse.status, 404); assert.deepEqual(await notFoundResponse.json(), { error: "Not found" }); + const invalidRoomRoute = await worker.fetch( + new Request("https://worker.test/rooms/x/join", { method: "POST" }), + {}, + ); + assert.equal(invalidRoomRoute.status, 400); + assert.deepEqual(await invalidRoomRoute.json(), { error: "Invalid room code" }); + const namespace = new FakeBattleRoomNamespace(); const joinResponse = await worker.fetch( new Request("https://worker.test/rooms/ab12/join", { method: "POST" }), @@ -374,6 +381,39 @@ test("worker returns auth errors for invalid Telegram login payloads", async () assert.deepEqual(await response.json(), { error: "Telegram payload is incomplete" }); }); +test("worker redacts Telegram session persistence failures", async () => { + const botToken = "123456:sensitive-bot-token"; + const payload = { + id: "42", + first_name: "Ivan", + username: "ivan", + auth_date: String(Math.floor(Date.now() / 1000)), + }; + payload.hash = await signTelegramPayload(payload, botToken); + + const response = await worker.fetch( + new Request("https://worker.test/auth/telegram", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + { TELEGRAM_BOT_TOKEN: botToken, DB: new FailingD1() }, + ); + + const body = await response.text(); + assert.equal(response.status, 401); + assert.deepEqual(JSON.parse(body), { error: "Telegram authentication failed" }); + assert.equal(body.includes(botToken), false); + assert.equal(body.includes(payload.hash), false); +}); + +test("worker handles unavailable leaderboard storage as a bounded route error", async () => { + const response = await worker.fetch(new Request("https://worker.test/leaderboard"), {}); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "Profile storage is not configured" }); +}); + test("BattleRoom rejects invalid room auth tokens as unauthorized responses", async () => { const room = new BattleRoom({ storage: new MemoryStorage() }, { DB: new RecordingD1() }); const response = await room.fetch( @@ -422,6 +462,31 @@ test("BattleRoom requires Telegram sessions to create and join rooms", async () assert.equal(savedRoom.players.p2.user.id, "2"); }); +test("BattleRoom rejects the creator joining the same room as p2", async () => { + const storage = new MemoryStorage(); + const env = { DB: new RecordingD1() }; + const room = new BattleRoom({ storage }, env); + const creatorHeaders = await authHeaders( + { provider: "telegram", id: "1", name: "One", username: "one", photoUrl: "" }, + env, + ); + + const created = await room.fetch( + new Request("https://worker.test/rooms?code=ROOM01", { method: "POST", headers: creatorHeaders }), + ); + assert.equal(created.status, 200); + + const joined = await room.fetch( + new Request("https://worker.test/rooms/ROOM01/join", { method: "POST", headers: creatorHeaders }), + ); + + assert.equal(joined.status, 409); + assert.deepEqual(await joined.json(), { error: "Room is full" }); + const savedRoom = await storage.get("room"); + assert.equal(savedRoom.players.p1.user.id, "1"); + assert.equal(savedRoom.players.p2, null); +}); + test("BattleRoom authenticates before revealing that a room already exists", async () => { const storedRoom = { code: "ROOM01", @@ -607,6 +672,16 @@ test("BattleRoom accepts WebSocket connections and removes closed sessions", asy FakeSocketPair.last.server.dispatch("close", {}); assert.equal(room.sessions.size, 0); + + const errorResponse = await room.fetch( + new Request("https://worker.test/rooms/SOCKET/socket?playerId=p1&token=p1-token", { + headers: { Upgrade: "websocket" }, + }), + ); + assert.equal(errorResponse.status, 101); + assert.equal(room.sessions.size, 1); + FakeSocketPair.last.server.dispatch("error", {}); + assert.equal(room.sessions.size, 0); } finally { restore(); } @@ -669,6 +744,10 @@ test("BattleRoom places fleets, starts games, and reports setup message errors", await errorRoom.handleMessage("session", JSON.stringify({ type: "placeFleet", board: p1Board, presetId: "quick" })); await errorRoom.handleMessage("session", "{bad-json"); await errorRoom.handleMessage("session", JSON.stringify({ type: "fire", coordinate: { row: 0, col: 0 } })); + await errorRoom.handleMessage( + "session", + JSON.stringify({ type: "requestRematch", board: p1Board, presetId: "quick" }), + ); } finally { globalThis.WebSocket = previousErrorWebSocket; } @@ -676,6 +755,7 @@ test("BattleRoom places fleets, starts games, and reports setup message errors", assert.equal(errorSent[0].message, "Room uses a different battle format"); assert.match(errorSent[1].message, /Expected property name or '\}' in JSON at position 1/); assert.equal(errorSent[2].message, "Game has not started"); + assert.equal(errorSent[3].message, "Rematch is available after a finished game"); }); test("BattleRoom canonically rebuilds setup boards and fire coordinates", async () => { @@ -725,6 +805,84 @@ test("BattleRoom canonically rebuilds setup boards and fire coordinates", async ); }); +test("BattleRoom rejects malformed setup state and canonically rebuilds marker-enabled boards", async () => { + const validQuickBoard = randomlyPlaceSetup(gamePresets.quick, () => 0.24); + const wrongSize = structuredClone(validQuickBoard); + wrongSize.size -= 1; + const missingArrays = structuredClone(validQuickBoard); + delete missingArrays.markers; + const incompleteFleet = structuredClone(validQuickBoard); + incompleteFleet.ships.pop(); + const wrongShipIdentity = structuredClone(validQuickBoard); + wrongShipIdentity.ships[0].id = "unexpected-ship"; + const missingShipCells = structuredClone(validQuickBoard); + missingShipCells.ships[0].cells = []; + const noncanonicalShip = structuredClone(validQuickBoard); + noncanonicalShip.ships[0].cells.reverse(); + const invalidCoordinate = structuredClone(validQuickBoard); + invalidCoordinate.ships[0].cells[0].row = 0.5; + const cases = [ + [wrongSize, "Invalid board size"], + [missingArrays, "Invalid board setup"], + [incompleteFleet, "A complete legal setup is required"], + [wrongShipIdentity, "A complete legal setup is required"], + [missingShipCells, "Ship cells are invalid"], + [noncanonicalShip, "Ship cells must be contiguous and canonical"], + [invalidCoordinate, "Invalid coordinate"], + ]; + const storage = new MemoryStorage({ + room: { + code: "MALFORMED", + players: { p1: { token: "p1-token", board: null, user: null }, p2: null }, + presetId: "quick", + game: null, + }, + }); + const room = new BattleRoom({ storage }); + const errors = []; + const previousWebSocket = globalThis.WebSocket; + globalThis.WebSocket = { OPEN: 1 }; + try { + room.sessions.set("p1", { playerId: "p1", socket: recordingSocket(errors) }); + for (const [board, expectedMessage] of cases) { + await room.handleMessage("p1", JSON.stringify({ type: "placeFleet", board, presetId: "quick" })); + assert.equal(errors.at(-1).message, expectedMessage); + assert.equal((await storage.get("room")).players.p1.board, null); + } + + const validPerelmanBoard = randomlyPlaceSetup(gamePresets.perelman, () => 0.48); + const invalidMarker = structuredClone(validPerelmanBoard); + invalidMarker.markers[0].type = "sweeper"; + const markerStorage = new MemoryStorage({ + room: { + code: "MARKERS", + players: { p1: { token: "p1-token", board: null, user: null }, p2: null }, + presetId: "perelman", + game: null, + }, + }); + const markerRoom = new BattleRoom({ storage: markerStorage }); + markerRoom.sessions.set("p1", { playerId: "p1", socket: recordingSocket(errors) }); + + await markerRoom.handleMessage( + "p1", + JSON.stringify({ type: "placeFleet", board: invalidMarker, presetId: "perelman" }), + ); + assert.equal(errors.at(-1).message, "A complete legal setup is required"); + assert.equal((await markerStorage.get("room")).players.p1.board, null); + + await markerRoom.handleMessage( + "p1", + JSON.stringify({ type: "placeFleet", board: validPerelmanBoard, presetId: "perelman" }), + ); + const savedMarkers = (await markerStorage.get("room")).players.p1.board.markers; + assert.deepEqual(savedMarkers, validPerelmanBoard.markers); + assert.notEqual(savedMarkers, validPerelmanBoard.markers); + } finally { + globalThis.WebSocket = previousWebSocket; + } +}); + test("BattleRoom restarts a finished online room when both players request a rematch", async () => { const firstP1Board = randomlyPlaceSetup(gamePresets.quick, () => 0.12); const firstP2Board = randomlyPlaceSetup(gamePresets.quick, () => 0.72); @@ -1073,6 +1231,32 @@ test("BattleRoom schedules an alarm before atomically persisting a finished room assert.ok(scheduleIndex < transactionEnd); }); +test("BattleRoom reuses an existing archive outbox job without replacing it", async () => { + const state = finishedOnlineRoom(); + const existingDueAt = 900_000; + const existingJob = archiveJobFixture(state.replayId); + existingJob.retry.nextAttemptAt = existingDueAt; + const outboxKey = `replayArchiveOutbox:${state.replayId}`; + const scheduleKey = archiveScheduleKey(existingDueAt, state.replayId); + const storage = new MemoryStorage({ + room: state, + [outboxKey]: existingJob, + [scheduleKey]: { replayId: state.replayId, dueAt: existingDueAt }, + }); + const db = new RecordingD1(); + const room = new BattleRoom({ storage }, { DB: db }); + storage.operations.length = 0; + + await room.recordFinishedOnlineBattle(state); + + assert.equal(db.replays.length, 1); + assert.equal(db.matches.length, 2); + assert.equal((await storage.get("room")).replayRecordedAt, state.finishedAt); + assert.equal(storage.operations.includes(`put:${outboxKey}`), false); + assert.equal(await storage.get(outboxKey), undefined); + assert.equal(await storage.get(scheduleKey), undefined); +}); + test("BattleRoom alarm recovers a finished unrecorded room without a schedule job", async () => { const state = finishedOnlineRoom(); const storage = new MemoryStorage({ room: state }); @@ -1088,11 +1272,38 @@ test("BattleRoom alarm recovers a finished unrecorded room without a schedule jo assert.equal(await archiveScheduleEntries(storage), 0); }); +test("BattleRoom alarm removes orphaned due schedules and clears the alarm", async () => { + const dueAt = 1_000_000; + const replayId = "00000000-0000-4000-8000-000000000404"; + const scheduleKey = archiveScheduleKey(dueAt, replayId); + const storage = new MemoryStorage({ + [scheduleKey]: { replayId, dueAt }, + }); + const room = new BattleRoom({ storage }, { DB: new RecordingD1() }); + const originalNow = Date.now; + Date.now = () => dueAt; + try { + await room.alarm(); + } finally { + Date.now = originalNow; + } + + assert.equal(await storage.get(scheduleKey), undefined); + assert.equal(await archiveScheduleEntries(storage), 0); + assert.equal(storage.alarmAt, undefined); + assert.equal(storage.operations.includes(`delete:${scheduleKey}`), true); +}); + test("BattleRoom terminal payload errors preserve and broadcast the finished result without retrying", async () => { const state = finishedOnlineRoom(); state.players.p1.user.sessionSecret = "must-not-survive"; state.players.p2.user.accessToken = "must-not-survive-either"; state.game.players.p1.board.ships[0].cells[0].sessionSecret = "nested-terminal-secret"; + state.game.players.p1.board.markers.push({ + id: "mine-1", + type: "mine", + cell: { row: 0, col: 1, sessionSecret: "marker-terminal-secret" }, + }); state.game = { ...state.game, phase: "playing", winnerId: null, presetId: "" }; state.replayId = undefined; state.finishedAt = undefined; @@ -1122,14 +1333,45 @@ test("BattleRoom terminal payload errors preserve and broadcast the finished res assert.equal(deadLetter.classification, "invalid_payload"); assert.equal(deadLetter.terminalData.players.p1.user.id, "1"); assert.equal(deadLetter.terminalData.game.players.p1.board.size, 2); + assert.deepEqual(deadLetter.terminalData.game.players.p1.board.markers, [ + { id: "mine-1", type: "mine", cell: { row: 0, col: 1 } }, + ]); assert.ok(Array.isArray(deadLetter.terminalData.game.log)); const serializedDeadLetter = JSON.stringify(deadLetter); - assert.doesNotMatch(serializedDeadLetter, /sessionSecret|accessToken|must-not-survive|nested-terminal-secret/); + assert.doesNotMatch( + serializedDeadLetter, + /sessionSecret|accessToken|must-not-survive|nested-terminal-secret|marker-terminal-secret/, + ); assert.doesNotMatch(serializedDeadLetter, /p1-token|p2-token/); assert.equal(await archiveScheduleEntries(storage), 0); assert.equal(storage.alarmAt, undefined); }); +test("BattleRoom terminal recovery preserves a missing game as null", async () => { + const replayId = "00000000-0000-4000-8000-000000000405"; + const storage = new MemoryStorage(); + const room = new BattleRoom({ storage }); + const originalError = console.error; + console.error = () => {}; + try { + await room.persistTerminalPayloadFailure( + { + replayId, + finishedAt: "2026-07-11T12:00:00.000Z", + players: { p1: null, p2: null }, + game: null, + }, + new Error("Malformed durable room state"), + ); + } finally { + console.error = originalError; + } + + const [deadLetter] = await archiveDeadLetterEntries(storage); + assert.equal(deadLetter.classification, "invalid_payload"); + assert.equal(deadLetter.terminalData.game, null); +}); + test("BattleRoom can validate and requeue a repaired invalid-payload dead letter", async () => { const state = finishedOnlineRoom(); state.game.presetId = ""; @@ -1145,10 +1387,31 @@ test("BattleRoom can validate and requeue a repaired invalid-payload dead letter } const repairedEnvelope = archiveJobFixture(state.replayId).envelope; - await assert.rejects( - room.requeueArchiveDeadLetter(state.replayId, { ...repairedEnvelope, playerMatches: [] }), - /invalid/i, - ); + const duplicateSide = structuredClone(repairedEnvelope); + duplicateSide.playerMatches[1].playerId = "p1"; + const mismatchedIdentity = structuredClone(repairedEnvelope); + mismatchedIdentity.playerMatches[0].user.id = "mismatched-user"; + const invalidMatchLink = structuredClone(repairedEnvelope); + invalidMatchLink.playerMatches[0].payload.id = "wrong-match-id"; + const negativeStats = structuredClone(repairedEnvelope); + negativeStats.playerMatches[0].payload.playerShots = -1; + const excessiveAccuracy = structuredClone(repairedEnvelope); + excessiveAccuracy.playerMatches[0].payload.accuracy = 101; + const invalidRepairs = [ + {}, + { ...repairedEnvelope, playerMatches: [] }, + duplicateSide, + mismatchedIdentity, + invalidMatchLink, + negativeStats, + excessiveAccuracy, + ]; + for (const invalidRepair of invalidRepairs) { + await assert.rejects( + room.requeueArchiveDeadLetter(state.replayId, invalidRepair), + /Repaired replay envelope is invalid/, + ); + } assert.equal(await room.requeueArchiveDeadLetter(state.replayId, repairedEnvelope), true); await room.drainArchiveOutbox(); assert.equal(db.replays.length, 1); @@ -1159,6 +1422,42 @@ test("BattleRoom can validate and requeue a repaired invalid-payload dead letter assert.equal(db.matches.length, 2); }); +test("BattleRoom rejects incompatible dead-letter requeue modes without mutation", async () => { + const invalidPayloadId = "00000000-0000-4000-8000-000000000406"; + const repairForbiddenId = "00000000-0000-4000-8000-000000000407"; + const missingEnvelopeId = "00000000-0000-4000-8000-000000000408"; + const storage = new MemoryStorage({ + [`replayArchiveDeadLetter:${invalidPayloadId}`]: { + replayId: invalidPayloadId, + classification: "invalid_payload", + envelope: null, + }, + [`replayArchiveDeadLetter:${repairForbiddenId}`]: { + replayId: repairForbiddenId, + classification: "retry_exhausted", + envelope: archiveJobFixture(repairForbiddenId).envelope, + }, + [`replayArchiveDeadLetter:${missingEnvelopeId}`]: { + replayId: missingEnvelopeId, + classification: "configuration", + envelope: null, + }, + }); + const room = new BattleRoom({ storage }); + + assert.equal(await room.requeueArchiveDeadLetter(invalidPayloadId), false); + await assert.rejects( + room.requeueArchiveDeadLetter(repairForbiddenId, archiveJobFixture(repairForbiddenId).envelope), + /repaired envelope is only valid for invalid payload dead letters/i, + ); + assert.equal(await room.requeueArchiveDeadLetter(missingEnvelopeId), false); + + assert.equal((await archiveDeadLetterEntries(storage)).length, 3); + assert.deepEqual(await archiveOutboxEntries(storage), []); + assert.equal(await archiveScheduleEntries(storage), 0); + assert.equal(storage.alarmAt, undefined); +}); + test("BattleRoom missing replay participants are terminal without retry", async () => { const state = finishedOnlineRoom(); state.players.p2.user = null; diff --git a/worker/index.js b/worker/index.js index 7e0d472..657b3e0 100644 --- a/worker/index.js +++ b/worker/index.js @@ -19,7 +19,14 @@ import { oidcConfigured, verifyTelegramIdToken, } from "./telegram-oidc.js"; -import { getLeaderboard, getPlayerProfile, recordCompletedMatch, recordOnlineReplayBatch } from "./profile.js"; +import { verifyTelegramMiniAppInitData } from "./telegram-mini-app-auth.js"; +import { + getLeaderboard, + getPlayerProfile, + recordCompletedMatch, + recordOnlineReplayBatch, + userSubject, +} from "./profile.js"; import { HttpError, createOnlineReplayRecord, @@ -38,6 +45,8 @@ const telegramWebTarget = "https://agent-axiom.github.io/agents-salvo/"; const telegramFlowTtlSeconds = 5 * 60; const telegramTicketTtlSeconds = 5 * 60; const maxTelegramJsonBytes = 1024; +const telegramMiniAppJsonEnvelopeBytes = 15; +const maxTelegramMiniAppJsonBytes = 16 * 1024 + telegramMiniAppJsonEnvelopeBytes; const maxTelegramCodeLength = 4096; const telegramSecretPattern = /^[A-Za-z0-9_-]{43}$/; const telegramPlatforms = new Set(["web", "android", "ios"]); @@ -81,6 +90,12 @@ export default { if (route.kind.startsWith("authTelegramMobile") || route.kind === "authTelegramConfig") { return json({ error: "Not found" }, 404); } + if (route.kind === "authTelegramMiniApp") { + if (request.method === "POST") { + return authenticateTelegramMiniApp(request, env); + } + return json({ error: "Not found" }, 404); + } if (route.kind === "authTelegram" && request.method === "POST") { return authenticateTelegram(request, env); } @@ -175,7 +190,7 @@ export class BattleRoom { async join(request, roomCode) { const { user } = await authorizeRequest(request, this.env); const room = await this.requireRoom(); - if (room.players.p2) { + if (room.players.p2 || userSubject(room.players.p1.user) === userSubject(user)) { return json({ error: "Room is full" }, 409); } @@ -741,6 +756,9 @@ function routeRequest(url) { if (url.pathname === "/auth/telegram/mobile/redeem") { return { kind: "authTelegramMobileRedeem" }; } + if (url.pathname === "/auth/telegram/miniapp") { + return { kind: "authTelegramMiniApp" }; + } const parts = url.pathname.split("/").filter(Boolean); if (parts.length === 2 && parts[0] === "auth" && parts[1] === "telegram") { return { kind: "authTelegram" }; @@ -795,6 +813,49 @@ async function authenticateTelegram(request, env) { } } +async function authenticateTelegramMiniApp(request, env) { + const config = telegramMiniAppServiceConfig(env); + if (!config) { + return telegramMiniAppAuthFailure(503); + } + + let initData; + try { + ({ initData } = await readStrictTelegramJson(request, "initData", maxTelegramMiniAppJsonBytes)); + } catch { + return telegramMiniAppAuthFailure(401); + } + + let user; + try { + ({ user } = await verifyTelegramMiniAppInitData(initData, config.botToken)); + } catch { + return telegramMiniAppAuthFailure(401); + } + + try { + const { token } = await createSession(config.db, user); + return json({ token, user }); + } catch { + return telegramMiniAppAuthFailure(503); + } +} + +function telegramMiniAppServiceConfig(env) { + try { + const db = env?.DB; + const botToken = env?.TELEGRAM_BOT_TOKEN; + if (!db || typeof botToken !== "string" || botToken.trim() === "") return null; + return { db, botToken }; + } catch { + return null; + } +} + +function telegramMiniAppAuthFailure(status) { + return json({ error: "Telegram Mini App authentication failed" }, status); +} + async function startTelegramMobileAuth(request, env, ctx) { if (!oidcConfigured(env) || !env.DB) { return json({ error: "Telegram OIDC unavailable" }, 503); @@ -936,13 +997,13 @@ async function redeemTelegramMobileTicket(request, env, ctx) { } } -async function readStrictTelegramJson(request, requiredKey) { +async function readStrictTelegramJson(request, requiredKey, maxBytes = maxTelegramJsonBytes) { const contentType = request.headers.get("Content-Type") ?? ""; if (!/^application\/json(?:\s*;|$)/i.test(contentType)) { throw new Error("Invalid content type"); } const contentLength = Number(request.headers.get("Content-Length")); - if (Number.isFinite(contentLength) && contentLength > maxTelegramJsonBytes) { + if (Number.isFinite(contentLength) && contentLength > maxBytes) { throw new Error("Request too large"); } @@ -959,7 +1020,7 @@ async function readStrictTelegramJson(request, requiredKey) { break; } length += value.byteLength; - if (length > maxTelegramJsonBytes) { + if (length > maxBytes) { await reader.cancel().catch(() => {}); throw new Error("Request too large"); } diff --git a/worker/profile.js b/worker/profile.js index cd9d34e..e43a002 100644 --- a/worker/profile.js +++ b/worker/profile.js @@ -198,6 +198,17 @@ export async function recordOnlineReplayBatch(db, replay, playerMatches) { if (normalized.some(({ match }) => match.mode !== "online" || match.replayId !== replay.id)) { throw new Error("Online replay match linkage is invalid"); } + const participants = new Map(normalized.map((participant) => [participant.playerId, participant])); + if ( + participants.size !== 2 || + !participants.has("p1") || + !participants.has("p2") || + replay.p1UserKey === replay.p2UserKey || + participants.get("p1").userKey !== replay.p1UserKey || + participants.get("p2").userKey !== replay.p2UserKey + ) { + throw new Error("Online replay participant identities are invalid"); + } const now = new Date().toISOString(); const statements = [ diff --git a/worker/telegram-mini-app-auth.js b/worker/telegram-mini-app-auth.js new file mode 100644 index 0000000..411671b --- /dev/null +++ b/worker/telegram-mini-app-auth.js @@ -0,0 +1,229 @@ +const textEncoder = new TextEncoder(); +const authenticationErrorMessage = "Telegram Mini App authentication failed"; +const maxInitDataBytes = 16 * 1024; +const maxTelegramId = 2 ** 52 - 1; +const maxNamePartLength = 128; +const maxUsernameLength = 64; +const maxLanguageCodeLength = 35; +const maxPhotoUrlLength = 2048; +const maxStartParamLength = 512; +const allowedFields = new Set([ + "auth_date", + "can_send_after", + "chat", + "chat_instance", + "chat_type", + "hash", + "query_id", + "receiver", + "signature", + "start_param", + "user", +]); + +export async function verifyTelegramMiniAppInitData( + rawInitData, + botToken, + { + now = Math.floor(Date.now() / 1000), + maxAgeSeconds = 300, + maxFutureSeconds = 60, + } = {}, +) { + try { + if (typeof botToken !== "string" || botToken.length === 0) { + throw authenticationError(); + } + if ( + !Number.isFinite(now) || + !Number.isFinite(maxAgeSeconds) || + maxAgeSeconds < 0 || + !Number.isFinite(maxFutureSeconds) || + maxFutureSeconds < 0 + ) { + throw authenticationError(); + } + + const fields = parseInitData(rawInitData); + const suppliedHash = requireHexHash(fields.get("hash")); + const dataCheckString = [...fields] + .filter(([key]) => key !== "hash") + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + const secret = await hmac(textEncoder.encode("WebAppData"), botToken); + const expectedHash = bytesToHex(await hmac(secret, dataCheckString)); + if (!timingSafeEqualHex(expectedHash, suppliedHash)) { + throw authenticationError(); + } + + const authDate = strictEpoch(fields.get("auth_date")); + if (now - authDate > maxAgeSeconds || authDate - now > maxFutureSeconds) { + throw authenticationError(); + } + return normalizeResult(fields); + } catch { + throw authenticationError(); + } +} + +function parseInitData(rawInitData) { + if ( + typeof rawInitData !== "string" || + rawInitData.length === 0 || + textEncoder.encode(rawInitData).byteLength > maxInitDataBytes + ) { + throw authenticationError(); + } + + validateQueryEncoding(rawInitData); + const fields = new Map(); + for (const [key, value] of new URLSearchParams(rawInitData)) { + if (!allowedFields.has(key) || fields.has(key)) { + throw authenticationError(); + } + fields.set(key, value); + } + for (const requiredField of ["hash", "auth_date", "user"]) { + if (!fields.has(requiredField)) { + throw authenticationError(); + } + } + return fields; +} + +function validateQueryEncoding(rawInitData) { + for (const component of rawInitData.split("&")) { + const separatorIndex = component.indexOf("="); + if (separatorIndex <= 0) { + throw authenticationError(); + } + decodeQueryComponent(component.slice(0, separatorIndex)); + decodeQueryComponent(component.slice(separatorIndex + 1)); + } +} + +function decodeQueryComponent(value) { + try { + decodeURIComponent(value.replaceAll("+", " ")); + } catch { + throw authenticationError(); + } +} + +function requireHexHash(value) { + if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) { + throw authenticationError(); + } + return value; +} + +function strictEpoch(value) { + if (typeof value !== "string" || !/^[1-9]\d*$/.test(value)) { + throw authenticationError(); + } + const epoch = Number(value); + if (!Number.isSafeInteger(epoch)) { + throw authenticationError(); + } + return epoch; +} + +function normalizeResult(fields) { + const telegramUser = JSON.parse(fields.get("user")); + if (!isPlainObject(telegramUser)) { + throw authenticationError(); + } + if ( + !Number.isSafeInteger(telegramUser.id) || + telegramUser.id <= 0 || + telegramUser.id > maxTelegramId || + (telegramUser.is_bot !== undefined && telegramUser.is_bot !== false) + ) { + throw authenticationError(); + } + + const firstName = requiredBoundedString(telegramUser.first_name, maxNamePartLength).trim(); + const lastName = optionalBoundedString(telegramUser.last_name, maxNamePartLength).trim(); + const username = optionalBoundedString(telegramUser.username, maxUsernameLength).trim(); + const languageCode = optionalBoundedString(telegramUser.language_code, maxLanguageCodeLength).trim(); + const photoUrl = optionalBoundedString(telegramUser.photo_url, maxPhotoUrlLength); + const startParam = optionalBoundedString(fields.get("start_param"), maxStartParamLength); + if (!firstName || !validPhotoUrl(photoUrl)) { + throw authenticationError(); + } + + return { + user: { + provider: "telegram", + id: String(telegramUser.id), + name: [firstName, lastName].filter(Boolean).join(" "), + username, + photoUrl, + }, + languageCode, + startParam, + }; +} + +function requiredBoundedString(value, maximumLength) { + if (typeof value !== "string" || value.length === 0 || value.length > maximumLength) { + throw authenticationError(); + } + return value; +} + +function optionalBoundedString(value, maximumLength) { + if (value === undefined) { + return ""; + } + if (typeof value !== "string" || value.length > maximumLength) { + throw authenticationError(); + } + return value; +} + +function validPhotoUrl(value) { + if (!value) { + return true; + } + try { + return value === value.trim() && new URL(value).protocol === "https:"; + } catch { + return false; + } +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +async function hmac(secret, value) { + const key = await crypto.subtle.importKey( + "raw", + secret, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array(await crypto.subtle.sign("HMAC", key, textEncoder.encode(value))); +} + +function timingSafeEqualHex(left, right) { + if (left.length !== right.length) { + return false; + } + let difference = 0; + for (let index = 0; index < left.length; index += 1) { + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + } + return difference === 0; +} + +function bytesToHex(bytes) { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function authenticationError() { + return new Error(authenticationErrorMessage); +}