From 8885394424ac9fc5f2f6dbbf06843763c2562391 Mon Sep 17 00:00:00 2001 From: sadfun Date: Sat, 5 Sep 2026 23:07:05 +0200 Subject: [PATCH 1/3] Add standalone web app with messenger admin sign-in --- .env.example | 4 +- .github/workflows/ci.yml | 1 + README.md | 21 +- docs/discord.md | 9 +- docs/slack.md | 18 +- src/channels/discord/channel.ts | 4 + src/channels/slack/channel.ts | 4 + src/channels/telegram/channel.ts | 4 + src/core/bridge.ts | 45 ++++ src/core/channel.ts | 2 + src/index.ts | 28 ++- src/miniapp/api.ts | 44 +++- src/miniapp/app.tsx | 345 +++++++++++++++++++++++-------- src/miniapp/browser-auth.ts | 120 +++++++++++ src/miniapp/client.tsx | 27 ++- src/miniapp/index.html | 8 +- src/miniapp/server.ts | 170 +++++++++++---- src/miniapp/sign-in.tsx | 84 ++++++++ src/miniapp/skills.tsx | 2 +- src/miniapp/styles.css | 303 ++++++++++++++++++++++++++- src/miniapp/telegram.ts | 3 +- src/miniapp/ui.tsx | 14 +- src/miniapp/usage-section.tsx | 3 +- test/browser-auth.test.ts | 87 ++++++++ test/channel-admin.test.ts | 73 +++++++ test/fixtures/web-app.ts | 162 +++++++++++++++ test/miniapp-browser.test.ts | 162 +++++++++++++++ test/web-command.test.ts | 80 +++++++ 28 files changed, 1654 insertions(+), 173 deletions(-) create mode 100644 src/miniapp/browser-auth.ts create mode 100644 src/miniapp/sign-in.tsx create mode 100644 test/browser-auth.test.ts create mode 100644 test/channel-admin.test.ts create mode 100644 test/fixtures/web-app.ts create mode 100644 test/miniapp-browser.test.ts create mode 100644 test/web-command.test.ts diff --git a/.env.example b/.env.example index 499b83a..fa0670a 100644 --- a/.env.example +++ b/.env.example @@ -21,8 +21,8 @@ # Optional: restrict instance-wide Discord commands to these users. # DISCORD_ADMIN_USER_IDS=123456789012345678 -# Public HTTPS origin serving the Mini App, normally through a reverse proxy. -# Leave it unset to expose the Mini App through a TryCloudflare quick tunnel +# Public HTTPS origin serving the web app and Telegram Mini App, normally through a reverse proxy. +# Leave it unset to expose the web app (with any messenger) through a TryCloudflare quick tunnel # instead: Wirebot installs a pinned, checksum-verified cloudflared into its # toolchains directory (or uses one already on the PATH) and gets a fresh # trycloudflare.com URL on every start. Set WIREBOT_TUNNEL=off to disable the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cb80e5..2ad6429 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: bun-version: 1.4.0 - run: bun install --frozen-lockfile - run: bun run check + - run: bun test - run: bun run compile - name: Compiled binary smoke test run: | diff --git a/README.md b/README.md index 95e8453..271f3b0 100644 --- a/README.md +++ b/README.md @@ -88,15 +88,15 @@ docker compose pull && docker compose up -d State lives in the volume, so recreating the container is safe. [Watchtower](https://containrrr.dev/watchtower/) or your orchestrator can automate the pull. Images are tagged `latest` and `X.Y.Z` on GHCR; pin a version tag if you prefer explicit upgrades. -### The settings Mini App URL +### The web app URL -To expose the authenticated settings Mini App, either set its public HTTPS origin: +To expose the web app and Telegram Mini App, set a public HTTPS origin: ```dotenv PUBLIC_URL=https://codex.example.com ``` -and reverse-proxy that origin to the container's port 8787 (publish it in your compose file), or leave `PUBLIC_URL` unset: Wirebot then opens a [TryCloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) using the bundled pinned cloudflared. Quick tunnels are best-effort — the URL changes on every start and Cloudflare offers no uptime guarantee — so set `PUBLIC_URL` for a persistent deployment. Set `WIREBOT_TUNNEL=off` to never open a tunnel; without a tunnel or `PUBLIC_URL`, Wirebot runs without the `/config` button. The Mini App validates signed Telegram `initData` against the allowlist regardless of how it is exposed. +and reverse-proxy that origin to the container's port 8787 (publish it in your compose file), or leave `PUBLIC_URL` unset: Wirebot then opens a [TryCloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) using the bundled pinned cloudflared. Quick tunnels are best-effort — the URL changes on every start and Cloudflare offers no uptime guarantee — so set `PUBLIC_URL` for a persistent deployment. Set `WIREBOT_TUNNEL=off` to never open a tunnel; without a tunnel or `PUBLIC_URL`, browser sign-in links and Telegram’s Settings button are unavailable, and Slack/Discord keep their in-chat settings picker. The HTTP app and health endpoint still run locally. The tunnel fallback works with any configured messenger, including Slack-only and Discord-only deployments. ### Configuration reference @@ -146,6 +146,7 @@ Voice messages use that same ChatGPT subscription. Wirebot briefly shows a **Tra | `/status` | Check app-server connectivity and the current Codex account. | | `/login` | Start Codex's ChatGPT device-code login in a private chat. | | `/logout` | Sign out through Codex in a private chat. | +| `/web` | Get a one-use link to sign in to the browser app (private chats, admins only). | | `/config` | Open the authenticated settings Mini App in a private chat. | | `/reload` | Reload config, MCP servers, and skills through Codex's native app-server APIs. | | `/restart` | Drain active work and safely restart only the Codex app-server. | @@ -163,7 +164,7 @@ Telegram's hosted Bot API only allows bots to download files up to 20 MB and upl Wirebot can additionally bridge Codex into Slack over [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) — no public URL required. Direct messages stream progress like the Telegram private chat; in channels the bot answers mentions in threads, with each thread acting as its own Codex conversation. Approvals arrive as buttons, files flow in both directions, and commands are available as `/wirebot ` (Slack reserves bare `/new`-style messages for its own slash-command system). Scheduled runs created from Slack notify back into the originating channel or thread. -Set `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `SLACK_ALLOWED_USER_IDS` together to enable it. [docs/slack.md](docs/slack.md) walks through creating the Slack app from a pasteable manifest, collecting both tokens, and first steps. The settings Mini App stays Telegram-only because it authenticates through Telegram `initData`. +Set `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `SLACK_ALLOWED_USER_IDS` together to enable it. [docs/slack.md](docs/slack.md) walks through creating the Slack app from a pasteable manifest, collecting both tokens, and first steps. Admins can open the full web app using `/wirebot config` or `/wirebot web` in a bot DM. No Telegram account is needed. ## Discord connector @@ -198,9 +199,17 @@ The service must be running when work becomes due; this is not a cloud scheduler Wirebot's design rule is to use Codex's native app-server behavior instead of building a custom agent harness. The scheduled-runs engine is the one exception because Codex Desktop already implements scheduling in its host application rather than in Codex CLI. Wirebot mirrors that approach nearly 1:1: the host claims due work, applies foreground priority, persists run and notification state, and asks Codex to execute normal turns. As soon as Codex CLI or app-server provides native cron ownership, Wirebot will switch to it immediately and retire this engine. -## Settings Mini App +## Web app and Telegram Mini App -The Mini App is pinned to the bot's **Settings** menu button when its public URL is available; `/config` remains an equivalent entry point. At startup Wirebot reconciles both Telegram's default button and each allowlisted private chat, so a stale chat-specific command-menu override cannot hide the Mini App. It uses source-owned UI components styled with Tailwind and accepts only signed Telegram `initData` from allowlisted private users. Its tab bar keeps **Settings** first, **Skills** second, and adds a **Schedules** screen for owner-scoped schedule management. The Skills screen lists every enabled skill from Codex's native `skills/list` response. Opening a skill shows its `SKILL.md` instructions and a read-only browser for bundled scripts, references, images, and other files. Skill paths remain confined to that skill's directory, and oversized files are not loaded into the browser. +Open `https://your-wirebot-origin/app` in a normal browser. Settings, Skills, and Schedules also have bookmarkable URLs at `/app/settings`, `/app/skills`, and `/app/schedules`; `/` and the existing `/miniapp` URL work too. Desktop browsers use a left sidebar, while phones use bottom navigation. Browser colors follow the system light/dark preference, independently of Telegram. Mobile layouts support safe areas, dynamic viewport height, readable form controls, and pinch zoom. + +For browser sign-in, send `/wirebot web` (or `/wirebot config`) in a private Slack or Discord bot chat; Telegram users can send `/web`. The bot replies with a private, one-use link that expires in 5 minutes. Open it in your preferred browser, or paste it into the app's sign-in screen on another device. Requesting a new link invalidates your previous unused link. After signing in, ordinary URLs work for 12 hours, including across reloads and new tabs. **Sign out** revokes that browser session; restarting Wirebot invalidates all links and sessions. + +Browser access follows the connector's bot-admin policy, not Slack workspace or Discord server roles: `SLACK_ADMIN_USER_IDS` and `DISCORD_ADMIN_USER_IDS` restrict access when configured, and otherwise every authorized user is an admin. All Telegram allowlisted users are admins. The server rechecks authorization when issuing links, exchanging them, and using a session (Slack workspace membership uses its existing 10-minute cache). Link secrets live in URL fragments, are removed from the address bar on load, and are exchanged for Secure, HttpOnly, SameSite cookies. Serve the public app over HTTPS and keep login links private. Browser auth uses no Telegram SDK, localStorage, or third-party authentication service. + +Schedules retain the signed-in messenger identity: you see schedules owned by that account, and new schedules use the private conversation from which you requested the link. Accounts on different messengers are separate identities. + +The Mini App is pinned to the bot's **Settings** menu button when its public URL is available; `/config` remains an equivalent entry point. At startup Wirebot reconciles both Telegram's default button and each allowlisted private chat, so a stale chat-specific command-menu override cannot hide the Mini App. It uses source-owned UI components styled with Tailwind and authenticates Telegram launches using signed `initData` from allowlisted users. Its tab bar keeps **Settings** first, **Skills** second, and adds a **Schedules** screen for owner-scoped schedule management. The Skills screen lists every enabled skill from Codex's native `skills/list` response. Opening a skill shows its `SKILL.md` instructions and a read-only browser for bundled scripts, references, images, and other files. Skill paths remain confined to that skill's directory, and oversized files are not loaded into the browser. Inside Telegram, nested Mini App screens use the native header back button; ordinary browser rendering keeps the in-page back controls. Every multiline input can open a focused full-screen diff --git a/docs/discord.md b/docs/discord.md index aa635bb..fb8947a 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -89,6 +89,7 @@ Wirebot owns one global application command with subcommands: /wirebot login /wirebot logout /wirebot config +/wirebot web /wirebot reload /wirebot restart /wirebot help @@ -96,8 +97,12 @@ Wirebot owns one global application command with subcommands: Conversation commands in a server must run inside a Discord thread because a root channel is not a stable task boundary. You can also write `!new` or `/new` as a normal direct message, or mention -the bot with that text in a server. `/wirebot config` opens a compact settings picker in a direct -message; the larger web Mini App remains Telegram-authenticated. +the bot with that text in a server. In a direct message, `/wirebot config` or `/wirebot web` +gives bot admins a private browser sign-in link. It expires after 5 minutes and works once; +the browser session lasts 12 hours. Set `PUBLIC_URL` to an HTTPS origin or use the automatic +quick tunnel. Without either, `/wirebot config` keeps the compact in-chat settings picker. +Browser access follows `DISCORD_ADMIN_USER_IDS`, independently of Discord server roles. +Schedules remain owned by the signed-in Discord user and new ones deliver to that private chat. Approvals and user-input prompts arrive as buttons. Only the user who received a prompt can answer it, and controls expire after five minutes or disappear when the requesting turn ends. Scheduled diff --git a/docs/slack.md b/docs/slack.md index fc7cc0b..fc2cf71 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -31,9 +31,9 @@ Wirebot validates and snapshots that file before uploading it with Slack's `files:write` permission. Local links used only as code references are not uploaded. -The settings Mini App remains Telegram-only because it authenticates through -Telegram. Everything else — including `/wirebot login` for the ChatGPT sign-in — -works from Slack. +The full web app works without Telegram. In a bot DM, `/wirebot config` or +`/wirebot web` gives admins a one-use browser sign-in link (valid for 5 minutes). +`/wirebot login` remains the separate ChatGPT sign-in command. ## 1. Create the Slack app @@ -115,10 +115,11 @@ Open it to the whole workspace only if that is acceptable. Optionally, `SLACK_ADMIN_USER_IDS` (comma-separated member IDs) restricts instance-wide commands — `/wirebot config`, `login`, `logout`, `reload`, and -`restart` — to the listed users. Unset, every authorized user may -run them. `/wirebot config` opens interactive Codex settings built from Slack -buttons (model, reasoning effort, speed tier, approvals, sandbox, web -search) in the bot DM — the Slack counterpart of the Telegram Mini App. +`restart`, plus browser sign-in with `web` — to the listed users. Unset, every authorized user may +run them. `/wirebot config` opens the web app through a private sign-in link. +Set `PUBLIC_URL` to an HTTPS origin or use the automatic quick tunnel. Without +either, `/wirebot config` keeps the compact in-chat settings picker. Browser +sessions last 12 hours and retain the Slack user identity for schedule ownership. ## 4. Configure Wirebot @@ -132,8 +133,7 @@ SLACK_ALLOWED_USER_IDS=U0123ABCDEF,U0456GHIJKL All three must be set together; leaving them all unset keeps the connector disabled. Telegram is optional when Slack is configured — with only the Slack -variables set, Wirebot runs Slack-only (the Telegram bot and the settings Mini -App stay off). Restart Wirebot and check the log for +variables set, Wirebot runs Slack-only with the same web app. Restart Wirebot and check the log for `Slack bot connected through Socket Mode`. ## 5. Talk to it diff --git a/src/channels/discord/channel.ts b/src/channels/discord/channel.ts index 5778ceb..f894d0a 100644 --- a/src/channels/discord/channel.ts +++ b/src/channels/discord/channel.ts @@ -173,6 +173,10 @@ export class DiscordChannel implements MessagingChannel { ); } + public async isAuthorizedAdmin(principal: ProviderReference): Promise { + return (await this.isAuthorized(principal)) && this.isAdmin(principal.id); + } + public async publish( targetReference: ProviderReference, message: OutboundMessage, diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts index 1ad4448..6068946 100644 --- a/src/channels/slack/channel.ts +++ b/src/channels/slack/channel.ts @@ -204,6 +204,10 @@ export class SlackChannel implements MessagingChannel { return this.isUserAllowed(principal.id); } + public async isAuthorizedAdmin(principal: ProviderReference): Promise { + return (await this.isAuthorized(principal)) && this.isAdmin(principal.id); + } + private isUserAllowed(userId: string): boolean | Promise { if (!this.#allowAllWorkspaceMembers) return this.#allowedUserIds.has(userId); const cached = this.#membership.get(userId); diff --git a/src/channels/telegram/channel.ts b/src/channels/telegram/channel.ts index 246089d..fdca765 100644 --- a/src/channels/telegram/channel.ts +++ b/src/channels/telegram/channel.ts @@ -216,6 +216,10 @@ export class TelegramChannel implements MessagingChannel { return Number.isSafeInteger(userId) && this.#allowedUserIds.has(userId); } + public async isAuthorizedAdmin(principal: ProviderReference): Promise { + return await this.isAuthorized(principal); + } + public async stop(): Promise { await this.#runner?.stop(); await this.#pendingChoices.declineAll("Request cancelled"); diff --git a/src/core/bridge.ts b/src/core/bridge.ts index b581a7a..88769bd 100644 --- a/src/core/bridge.ts +++ b/src/core/bridge.ts @@ -5,6 +5,7 @@ import type { Account } from "../generated/codex/v2/Account.js"; import type { AccountLoginCompletedNotification } from "../generated/codex/v2/AccountLoginCompletedNotification.js"; import type { GetAccountResponse } from "../generated/codex/v2/GetAccountResponse.js"; import type { LoginAccountResponse } from "../generated/codex/v2/LoginAccountResponse.js"; +import type { BrowserAuth } from "../miniapp/browser-auth.js"; import { BridgeError, errorMessage } from "../shared/errors.js"; import type { Logger } from "../shared/logger.js"; import { @@ -49,6 +50,11 @@ export const botCommands: readonly { }, { command: "login", menuDescription: "Sign in to Codex", help: "sign in to ChatGPT" }, { command: "logout", menuDescription: "Sign out of Codex", help: "sign out" }, + { + command: "web", + menuDescription: "Open Wirebot in a browser", + help: "get a temporary browser sign-in link", + }, { command: "config", menuDescription: "Open Codex settings", help: "open Codex settings" }, { command: "reload", @@ -77,6 +83,7 @@ export const conversationScopedCommands: ReadonlySet = new Set([ /** Commands that change shared instance state and require provider-admin gating. */ export const instanceAdminCommands: ReadonlySet = new Set([ "config", + "web", "login", "logout", "reload", @@ -119,6 +126,7 @@ interface PendingLogin { export class CodexBridge { readonly #codex: CodexService; + readonly #browserAuth: BrowserAuth | undefined; readonly #publicUrl: string | undefined; readonly #logger: Logger; readonly #runtimeCommand: CodexRuntimeCommand; @@ -158,9 +166,11 @@ export class CodexBridge { logger: Logger, runtimeCommand: CodexRuntimeCommand, scheduledRuns: ScheduledRunsEngine, + browserAuth?: BrowserAuth, ) { this.#codex = codex; this.#publicUrl = publicUrl; + this.#browserAuth = browserAuth; this.#logger = logger; this.#runtimeCommand = runtimeCommand; this.#scheduledRuns = scheduledRuns; @@ -292,7 +302,14 @@ export class CodexBridge { `Signed out of Codex. Send ${commandText(message.address.channel, "login")} whenever you want back in.`, ); return; + case "web": + await this.openBrowserApp(message); + return; case "config": + if (message.address.channel !== "telegram") { + await this.openBrowserApp(message); + return; + } if (!(await this.requirePrivateChat(message))) return; if (this.#publicUrl === undefined) { await message.responder.sendText( @@ -323,6 +340,34 @@ export class CodexBridge { } } + private async openBrowserApp(message: InboundMessage): Promise { + if (!(await this.requirePrivateChat(message))) return; + if (this.#publicUrl === undefined || this.#browserAuth === undefined) { + await message.responder.sendText( + "Browser sign-in needs a public URL. Set PUBLIC_URL to your HTTPS origin, or enable WIREBOT_TUNNEL=auto and restart.", + ); + return; + } + const deliveryTarget = message.address.deliveryTarget; + if (deliveryTarget === undefined) { + await message.responder.sendText("This messenger does not support browser sign-in yet."); + return; + } + const token = await this.#browserAuth.issue({ + owner: messageOwner(message), + conversation: messageConversation(message), + deliveryTarget, + }); + // Fragments are omitted from HTTP requests, including preview fetches. + const url = `${this.#publicUrl}/app#login=${token}`; + await message.responder.sendText( + "Sign in to Wirebot in your browser. This private link works once and expires in 5 minutes. Your browser session lasts 12 hours.", + { + button: { label: "Open Wirebot", kind: "url", url }, + }, + ); + } + private async handleSchedules(message: InboundMessage): Promise { const automations = this.#scheduledRuns.listForConversation( messageOwner(message), diff --git a/src/core/channel.ts b/src/core/channel.ts index a8fcc1e..acf554f 100644 --- a/src/core/channel.ts +++ b/src/core/channel.ts @@ -183,6 +183,8 @@ export interface MessagingChannel { readonly name: string; /** Re-check a persisted provider principal before unattended work executes. */ isAuthorized(principal: ProviderReference): boolean | Promise; + /** Re-check bot-admin access before issuing or using a browser session. Fail closed if absent. */ + isAuthorizedAdmin?(principal: ProviderReference): boolean | Promise; start(handler: MessageHandler): Promise; publish(target: ProviderReference, message: OutboundMessage): Promise; stop(): Promise; diff --git a/src/index.ts b/src/index.ts index 86b2277..5a5f681 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,8 +11,10 @@ import { CodexService, createContainerEnvironmentContext } from "./codex/service import { CodexToolchainManager, pinnedCodexVersion } from "./codex/toolchain.js"; import { loadAppConfig } from "./config/env.js"; import { CodexBridge } from "./core/bridge.js"; +import type { MessagingChannel } from "./core/channel.js"; import { ConversationStore } from "./core/conversation-store.js"; import { WirebotSettingsStore } from "./core/settings-store.js"; +import { BrowserAuth } from "./miniapp/browser-auth.js"; import { MiniAppServer } from "./miniapp/server.js"; import { QuickTunnel } from "./miniapp/tunnel.js"; import { deferred } from "./shared/async.js"; @@ -156,12 +158,16 @@ export async function runWirebot(): Promise { resources.push(runtime); await runtime.start(); - // The HTTP server always provides health; Mini App routes additionally - // require Telegram initData when that connector is configured. + const authChannels = new Map(); + const browserAuth = new BrowserAuth( + (owner) => authChannels.get(owner.provider)?.isAuthorizedAdmin?.(owner) ?? false, + ); + // The same HTTP application serves browsers and the Telegram Mini App. const miniApp = new MiniAppServer({ host: config.host, port: config.port, codex, + browserAuth, ...(config.telegram === undefined ? {} : { telegramAuth: config.telegram }), configService, runtime, @@ -173,7 +179,7 @@ export async function runWirebot(): Promise { await miniApp.start(); let publicUrl = config.publicUrl; - if (publicUrl === undefined && config.telegram !== undefined && config.tunnelMode === "auto") { + if (publicUrl === undefined && config.tunnelMode === "auto") { try { const tunnel = new QuickTunnel({ host: config.host, @@ -182,12 +188,12 @@ export async function runWirebot(): Promise { }); publicUrl = await tunnel.start(); resources.push(tunnel); - logger.info("The Mini App is exposed through a TryCloudflare quick tunnel", { + logger.info("The Wirebot web app is exposed through a TryCloudflare quick tunnel", { url: publicUrl, }); } catch (error) { logger.warn( - "The quick tunnel failed to start and no PUBLIC_URL is set; the settings Mini App is disabled. Set PUBLIC_URL, install cloudflared, or check outbound network access to Cloudflare.", + "The quick tunnel failed to start and no PUBLIC_URL is set; browser sign-in links are unavailable. Set PUBLIC_URL, install cloudflared, or check outbound network access to Cloudflare.", { error: errorMessage(error) }, ); } @@ -212,15 +218,20 @@ export async function runWirebot(): Promise { config.slack, join(config.workspace, ".wirebot", "attachments"), logger.child({ component: "slack" }), - configService, + publicUrl === undefined ? configService : undefined, ); const discord = config.discord === undefined ? undefined - : new DiscordChannel(config.discord, logger.child({ component: "discord" }), configService); + : new DiscordChannel( + config.discord, + logger.child({ component: "discord" }), + publicUrl === undefined ? configService : undefined, + ); const channels = [telegram, slack, discord].filter( (channel): channel is NonNullable => channel !== undefined, ); + for (const channel of channels) authChannels.set(channel.name, channel); const scheduledRuns = new ScheduledRunsEngine({ store: automations, codex, @@ -235,6 +246,7 @@ export async function runWirebot(): Promise { logger.child({ component: "bridge" }), runtime, scheduledRuns, + browserAuth, ); for (const channel of channels) { resources.push(channel); @@ -247,7 +259,7 @@ export async function runWirebot(): Promise { version: wirebotVersion, codexVersion: pinnedCodexVersion, workspace: config.workspace, - miniApp: config.telegram === undefined ? "disabled" : `${config.host}:${config.port}`, + webApp: publicUrl === undefined ? `${config.host}:${config.port}/app` : `${publicUrl}/app`, telegram: telegram === undefined ? "disabled" : "enabled", slack: slack === undefined ? "disabled" : "enabled", discord: discord === undefined ? "disabled" : "enabled", diff --git a/src/miniapp/api.ts b/src/miniapp/api.ts index 9456d38..83e1985 100644 --- a/src/miniapp/api.ts +++ b/src/miniapp/api.ts @@ -1,6 +1,6 @@ /** * Typed client for the Mini App HTTP API. Every request authenticates with - * the Telegram init data and surfaces server-reported validation issues as + * signed Telegram init data or a browser session and surfaces server-reported validation issues as * ConfigApiError. */ import type { ManagedSchedule } from "../automations/engine.js"; @@ -19,7 +19,7 @@ import type { import type { SkillResource } from "../codex/skill-browser.js"; import type { WirebotSettings } from "../core/settings-store.js"; import type { ConfigWriteResponse } from "../generated/codex/v2/ConfigWriteResponse.js"; -import { webApp } from "./telegram.js"; +import { telegramReady, webApp } from "./telegram.js"; /** The `/api/config` wire shape; the client trusts the server's typed JSON as-is. */ export type LoadedSnapshot = EditableConfigSnapshot & { @@ -29,25 +29,30 @@ export type LoadedSnapshot = EditableConfigSnapshot & { }; export class ConfigApiError extends Error { + public readonly status: number; public readonly issues: readonly ConfigValidationIssue[] | undefined; - public constructor(message: string, issues: readonly ConfigValidationIssue[] | undefined) { + public constructor( + message: string, + issues: readonly ConfigValidationIssue[] | undefined, + status = 0, + ) { super(message); this.name = "ConfigApiError"; this.issues = issues; + this.status = status; } } async function requestJson(path: string, init: RequestInit): Promise { - const initData = webApp?.initData; - if (initData === undefined || initData.length === 0) { - throw new Error("Telegram authorization is unavailable."); - } const hasBody = init.body !== undefined; const response = await fetch(path, { ...init, + credentials: "same-origin", headers: { - Authorization: `tma ${initData}`, + ...(telegramReady + ? { Authorization: `tma ${webApp?.initData}` } + : { "X-Wirebot-Request": "1" }), ...(hasBody ? { "Content-Type": "application/json" } : {}), }, }); @@ -55,11 +60,20 @@ async function requestJson(path: string, init: RequestInit): Promise { if (!response.ok) { const failure = value as { readonly error?: string; + readonly code?: string; readonly issues?: readonly ConfigValidationIssue[]; }; + if ( + !telegramReady && + (response.status === 401 || failure.code === "MINIAPP_FORBIDDEN") && + !path.startsWith("/api/auth/") + ) { + window.dispatchEvent(new Event("wirebot:session-expired")); + } throw new ConfigApiError( failure.error ?? `Request failed (${response.status}).`, failure.issues, + response.status, ); } return value; @@ -170,3 +184,17 @@ export async function requestSkillResource(skill: string, path: string): Promise const value = await requestJson(`/api/skills/resource?${query.toString()}`, { method: "GET" }); return value as SkillResource; } + +export async function requestSession(): Promise<{ readonly provider: string }> { + return (await requestJson("/api/auth/session", { method: "GET" })) as { + readonly provider: string; + }; +} + +export async function exchangeLogin(token: string): Promise { + await requestJson("/api/auth/exchange", { method: "POST", body: JSON.stringify({ token }) }); +} + +export async function logoutBrowser(): Promise { + await requestJson("/api/auth/logout", { method: "POST" }); +} diff --git a/src/miniapp/app.tsx b/src/miniapp/app.tsx index 511eeaa..743238f 100644 --- a/src/miniapp/app.tsx +++ b/src/miniapp/app.tsx @@ -1,122 +1,301 @@ -/** - * The Mini App shell: theme integration, the Settings/Skills/Schedules tab - * bar, and the initial config snapshot load for the Settings tab. - */ -import { CalendarClock, SlidersHorizontal, Sparkles } from "lucide-react"; -import { type ReactElement, useEffect, useState } from "react"; -import { type LoadedSnapshot, requestSnapshot } from "./api.js"; +/** Shared browser and Telegram application shell. */ +import { CalendarClock, LogOut, SlidersHorizontal, Sparkles, Terminal } from "lucide-react"; +import { type ReactElement, useEffect, useRef, useState } from "react"; +import { + ConfigApiError, + exchangeLogin, + type LoadedSnapshot, + logoutBrowser, + requestSession, + requestSnapshot, +} from "./api.js"; import { SchedulesManager } from "./schedules.js"; import { SettingsForm } from "./settings-form.js"; -import { useAsync } from "./shared.js"; +import { messageOf, useAsync } from "./shared.js"; +import { SignIn } from "./sign-in.js"; import { SkillsBrowser } from "./skills.js"; import { navigateWithUnsavedGuard, telegramReady, webApp } from "./telegram.js"; import { AppRoot, Button, Placeholder, Spinner, Tabbar } from "./ui.js"; type AppTab = "schedules" | "settings" | "skills"; +type Session = { readonly provider: string } | null; +const tabs = [ + { id: "settings", label: "Settings", icon: SlidersHorizontal }, + { id: "skills", label: "Skills", icon: Sparkles }, + { id: "schedules", label: "Schedules", icon: CalendarClock }, +] as const; -export function SettingsApp(): ReactElement { - const [appearance, setAppearance] = useState<"dark" | "light">(webApp?.colorScheme ?? "light"); - const [activeTab, setActiveTab] = useState("settings"); +function tabFromLocation(): AppTab { + const tab = telegramReady + ? new URLSearchParams(window.location.search).get("tab") + : window.location.pathname.split("/").at(-1); + return tab === "skills" || tab === "schedules" ? tab : "settings"; +} + +function tabUrl(tab: AppTab): string { + return telegramReady ? `/miniapp?tab=${tab}${window.location.hash}` : `/app/${tab}`; +} + +export function SettingsApp({ loginToken }: { readonly loginToken: string | null }): ReactElement { + const [appearance, setAppearance] = useState<"dark" | "light">( + webApp?.colorScheme ?? + (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"), + ); + const [session, setSession] = useState( + telegramReady ? { provider: "telegram" } : undefined, + ); + const [authError, setAuthError] = useState(); + const [authBusy, setAuthBusy] = useState(false); + const [activeTab, setActiveTab] = useState(tabFromLocation); const [loadAttempt, setLoadAttempt] = useState(0); const [snapshot, setSnapshot] = useState(); + const initialAuth = useRef | undefined>(undefined); useEffect(() => { const app = webApp; - if (app === undefined) return; - const handleThemeChanged = (): void => setAppearance(app.colorScheme); - app.ready(); - app.expand(); - app.onEvent("themeChanged", handleThemeChanged); - return () => app.offEvent("themeChanged", handleThemeChanged); + if (app !== undefined) { + const changed = (): void => setAppearance(app.colorScheme); + app.ready(); + app.expand(); + app.onEvent("themeChanged", changed); + return () => app.offEvent("themeChanged", changed); + } + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const changed = (): void => setAppearance(media.matches ? "dark" : "light"); + media.addEventListener("change", changed); + return () => media.removeEventListener("change", changed); + }, []); + + useEffect(() => { + document.documentElement.dataset.appearance = appearance; + document.documentElement.dataset.host = telegramReady ? "telegram" : "browser"; + }, [appearance]); + + useEffect(() => { + const viewport = window.visualViewport; + if (telegramReady || viewport === null) return; + const resize = (): void => { + document.documentElement.style.setProperty( + "--browser-viewport-height", + `${viewport.height}px`, + ); + document.documentElement.style.setProperty( + "--browser-viewport-top", + `${viewport.offsetTop}px`, + ); + }; + resize(); + viewport.addEventListener("resize", resize); + viewport.addEventListener("scroll", resize); + return () => { + viewport.removeEventListener("resize", resize); + viewport.removeEventListener("scroll", resize); + }; + }, []); + + useEffect(() => { + if (telegramReady) return; + let active = true; + initialAuth.current ??= (async () => { + if (loginToken !== null) await exchangeLogin(loginToken); + try { + return await requestSession(); + } catch (error) { + if (loginToken === null && error instanceof ConfigApiError && error.status === 401) + return null; + throw error; + } + })(); + void initialAuth.current + .then((value) => { + if (active) setSession(value); + }) + .catch((error: unknown) => { + if (!active) return; + setAuthError(messageOf(error)); + setSession(null); + }); + return () => { + active = false; + }; + }, [loginToken]); + + useEffect(() => { + const expired = (): void => { + setSession(null); + setSnapshot(undefined); + setAuthError("Your session ended. Request a fresh sign-in link from the bot."); + }; + window.addEventListener("wirebot:session-expired", expired); + return () => window.removeEventListener("wirebot:session-expired", expired); }, []); - const snapshotLoad = useAsync(telegramReady ? () => requestSnapshot("GET") : undefined, [ + useEffect(() => { + const onPopState = (): void => { + const next = tabFromLocation(); + if (next === activeTab) return; + // Restore the current page while the unsaved-changes guard asks the user. + window.history.replaceState(null, "", tabUrl(activeTab)); + navigateWithUnsavedGuard(() => { + window.history.replaceState(null, "", tabUrl(next)); + setActiveTab(next); + }); + }; + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, [activeTab]); + + const snapshotLoad = useAsync(session ? () => requestSnapshot("GET") : undefined, [ + session, loadAttempt, ]); - const loadError = telegramReady - ? snapshotLoad.error - : "Open this settings page from the bot in Telegram."; - useEffect(() => { if (snapshotLoad.value !== undefined) setSnapshot(snapshotLoad.value); }, [snapshotLoad.value]); - const selectTab = (nextTab: AppTab): void => { - if (nextTab === activeTab) return; - navigateWithUnsavedGuard(() => setActiveTab(nextTab)); + const signIn = async (token: string): Promise => { + setAuthBusy(true); + setAuthError(undefined); + try { + await exchangeLogin(token); + setSession(await requestSession()); + } catch (error) { + setAuthError(messageOf(error)); + } finally { + setAuthBusy(false); + } }; + const signOut = (): void => + navigateWithUnsavedGuard(() => { + setAuthBusy(true); + void logoutBrowser() + .then(() => { + setSession(null); + setSnapshot(undefined); + setAuthError(undefined); + }) + .catch((error: unknown) => setAuthError(messageOf(error))) + .finally(() => setAuthBusy(false)); + }); - const settingsContent = - snapshot === undefined ? ( - setLoadAttempt((attempt) => attempt + 1)} /> - ) : ( - - ); + const selectTab = (next: AppTab): void => { + if (next === activeTab) return; + navigateWithUnsavedGuard(() => { + window.history.pushState(null, "", tabUrl(next)); + setActiveTab(next); + window.scrollTo(0, 0); + }); + }; return ( - - {activeTab === "settings" ? ( - settingsContent - ) : activeTab === "skills" ? ( - + + {session === undefined ? ( +
+ + + +
+ ) : session === null ? ( + ) : ( - + <> + {!telegramReady && ( +
+ + Connected through {session.provider} + + +
+ )} + {authError && ( +

+ {authError} +

+ )} +
+ {activeTab === "settings" ? ( + snapshot === undefined ? ( + setLoadAttempt((attempt) => attempt + 1)} + /> + ) : ( + + ) + ) : activeTab === "skills" ? ( + + ) : ( + + )} +
+ + + )} - - selectTab("settings")} - aria-label="Settings" - > - - selectTab("skills")} - aria-label="Skills" - > - - selectTab("schedules")} - aria-label="Schedules" - > - -
); } -interface SettingsLoadingProps { +function SettingsLoading({ + error, + onRetry, +}: { readonly error: string | undefined; readonly onRetry: () => void; -} - -function SettingsLoading(props: SettingsLoadingProps): ReactElement { - if (props.error !== undefined) { - return ( -
+}): ReactElement { + return ( +
+ {error !== undefined ? ( Try again} + description={error} + action={} /> -
- ); - } - return ( -
- - - + ) : ( + + + + )}
); } diff --git a/src/miniapp/browser-auth.ts b/src/miniapp/browser-auth.ts new file mode 100644 index 0000000..9a6b122 --- /dev/null +++ b/src/miniapp/browser-auth.ts @@ -0,0 +1,120 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { ProviderReference } from "../core/channel.js"; +import { BridgeError } from "../shared/errors.js"; + +/** Provider-owned identity and destination, shared by both authentication methods. */ +export interface AppPrincipal { + readonly owner: ProviderReference; + readonly conversation: ProviderReference; + readonly deliveryTarget: ProviderReference; +} + +interface Grant { + readonly principal: AppPrincipal; + readonly expiresAt: number; +} + +export const loginLifetimeMs = 5 * 60 * 1_000; +export const sessionLifetimeMs = 12 * 60 * 60 * 1_000; +const capacity = 1_000; + +/** In-memory, hashed credentials: restarting Wirebot revokes all browser access. */ +export class BrowserAuth { + readonly #links = new Map(); + readonly #sessions = new Map(); + readonly #authorize: (owner: ProviderReference) => boolean | Promise; + readonly #now: () => number; + + public constructor( + authorize: (owner: ProviderReference) => boolean | Promise, + now: () => number = Date.now, + ) { + this.#authorize = authorize; + this.#now = now; + } + + public async issue(principal: AppPrincipal): Promise { + await this.requireAdmin(principal); + // Only the latest unused link for this admin remains valid. + for (const [key, grant] of this.#links) { + if (sameOwner(grant.principal, principal)) this.#links.delete(key); + } + return this.store(this.#links, principal, loginLifetimeMs); + } + + public async exchange(token: string): Promise { + const grant = this.lookup(this.#links, token); + // Consume synchronously before awaiting authorization to prevent concurrent redemption. + this.#links.delete(digest(token)); + await this.requireAdmin(grant.principal); + if (grant.expiresAt <= this.#now()) throw unauthorized(); + return this.store(this.#sessions, grant.principal, sessionLifetimeMs); + } + + public async authenticate(token: string): Promise { + const grant = this.lookup(this.#sessions, token); + await this.requireAdmin(grant.principal); + // Recheck after async authorization in case of concurrent logout or expiry. + this.lookup(this.#sessions, token); + return grant.principal; + } + + public revoke(token: string): void { + this.#sessions.delete(digest(token)); + } + + private async requireAdmin(principal: AppPrincipal): Promise { + const { owner, conversation, deliveryTarget } = principal; + if ( + owner.resource !== "user" || + conversation.resource !== "conversation" || + deliveryTarget.resource !== "destination" || + owner.provider !== conversation.provider || + owner.provider !== deliveryTarget.provider || + !(await this.#authorize(owner)) + ) { + throw new BridgeError("Browser access is limited to Wirebot admins.", "MINIAPP_FORBIDDEN"); + } + } + + private lookup(entries: Map, token: string): Grant { + if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw unauthorized(); + const key = digest(token); + const grant = entries.get(key); + if (grant === undefined || grant.expiresAt <= this.#now()) { + entries.delete(key); + throw unauthorized(); + } + return grant; + } + + private store(entries: Map, principal: AppPrincipal, lifetime: number): string { + for (const [key, grant] of entries) { + if (grant.expiresAt <= this.#now()) entries.delete(key); + } + if (entries.size >= capacity) { + throw new BridgeError("Too many browser sessions. Try again later.", "MINIAPP_FORBIDDEN"); + } + const token = randomBytes(32).toString("base64url"); + entries.set(digest(token), { + principal: structuredClone(principal), + expiresAt: this.#now() + lifetime, + }); + return token; + } +} + +function digest(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function sameOwner(left: AppPrincipal, right: AppPrincipal): boolean { + return left.owner.provider === right.owner.provider && left.owner.id === right.owner.id; +} + +function unauthorized(): BridgeError { + return new BridgeError( + "This sign-in has expired or was already used. Request a new link from the bot.", + "MINIAPP_UNAUTHORIZED", + ); +} diff --git a/src/miniapp/client.tsx b/src/miniapp/client.tsx index d98cef0..b64d3df 100644 --- a/src/miniapp/client.tsx +++ b/src/miniapp/client.tsx @@ -1,7 +1,26 @@ -/** Mini App browser entry: mounts the app shell into the page root. */ +/** Load Telegram's SDK only for a Mini App launch; ordinary browsers stay standalone. */ import { createRoot } from "react-dom/client"; -import { SettingsApp } from "./app.js"; +const launch = new URLSearchParams(window.location.hash.slice(1)); +const loginToken = launch.get("login"); +// Remove the secret before loading any other code or making a network request. +if (loginToken !== null) + window.history.replaceState(null, "", window.location.pathname + window.location.search); +if (loginToken === null && launch.has("tgWebAppData")) { + await new Promise((resolve) => { + const script = document.createElement("script"); + script.src = "https://telegram.org/js/telegram-web-app.js"; + script.onload = () => resolve(); + script.onerror = () => resolve(); + document.head.append(script); + }); +} +const { SettingsApp } = await import("./app.js"); const root = document.getElementById("root"); -if (root === null) throw new Error("Mini App root element is missing"); -createRoot(root).render(); +if (root === null) throw new Error("Wirebot root element is missing"); +createRoot(root).render(); + +// A sign-in link opened in an already-open tab can be a same-document navigation. +window.addEventListener("hashchange", () => { + if (new URLSearchParams(window.location.hash.slice(1)).has("login")) window.location.reload(); +}); diff --git a/src/miniapp/index.html b/src/miniapp/index.html index 786cb07..d2dab26 100644 --- a/src/miniapp/index.html +++ b/src/miniapp/index.html @@ -4,12 +4,12 @@ - - Wirebot settings - + + + Wirebot diff --git a/src/miniapp/server.ts b/src/miniapp/server.ts index ede7449..78173bc 100644 --- a/src/miniapp/server.ts +++ b/src/miniapp/server.ts @@ -23,13 +23,20 @@ import type { WirebotSettingsStore } from "../core/settings-store.js"; import type { GetAccountResponse } from "../generated/codex/v2/GetAccountResponse.js"; import { BridgeError, errorMessage } from "../shared/errors.js"; import type { Logger } from "../shared/logger.js"; -import { type TelegramInitDataUser, validateTelegramInitData } from "./auth.js"; +import { validateTelegramInitData } from "./auth.js"; +import { type AppPrincipal, type BrowserAuth, sessionLifetimeMs } from "./browser-auth.js"; const MAX_REQUEST_BYTES = 32 * 1_024; const MAX_AUTH_AGE_SECONDS = 60 * 60; const JSON_CONTENT_TYPE = "application/json; charset=utf-8"; const staticAssets = new Map([ + ...["/", "/app", "/app/", "/app/settings", "/app/skills", "/app/schedules"].map( + (path): [string, readonly ["index.html", string]] => [ + path, + ["index.html", "text/html; charset=utf-8"], + ], + ), ["/miniapp", ["index.html", "text/html; charset=utf-8"]], ["/miniapp/", ["index.html", "text/html; charset=utf-8"]], ["/miniapp/app.js", ["app.js", "text/javascript; charset=utf-8"]], @@ -58,10 +65,13 @@ export interface MiniAppServerOptions { readonly botToken: string; readonly allowedUserIds: ReadonlySet; }; + readonly browserAuth?: BrowserAuth; + /** Disable only for a local HTTP development server. Public deployments use HTTPS. */ + readonly secureCookies?: boolean; readonly codex: Pick; - readonly configService: CodexConfigService; + readonly configService: Pick; readonly runtime: MiniAppRuntimeController; - readonly settings: WirebotSettingsStore; + readonly settings: Pick; readonly logger: Logger; readonly assetDirectory?: string; readonly scheduledRuns?: MiniAppSchedulesController; @@ -106,7 +116,7 @@ export class MiniAppServer { void this.handle(request, response).catch((error: unknown) => { this.options.logger.error("Mini App request failed", error, { method: request.method, - path: request.url, + path: new URL(request.url ?? "/", "http://localhost").pathname, }); if (!response.headersSent) this.handleError(response, error); else response.destroy(); @@ -122,15 +132,13 @@ export class MiniAppServer { this.#scheduledRuns = controller; } - public async start(): Promise { - if (this.#started) return; - if (this.options.telegramAuth !== undefined) { - await Promise.all([ - access(join(this.#assetDirectory, "index.html")), - access(join(this.#assetDirectory, "app.js")), - access(join(this.#assetDirectory, "app.css")), - ]); - } + public async start(): Promise { + if (this.#started) return this.serverUrl(); + await Promise.all([ + access(join(this.#assetDirectory, "index.html")), + access(join(this.#assetDirectory, "app.js")), + access(join(this.#assetDirectory, "app.css")), + ]); await new Promise((resolve, reject) => { const onError = (error: Error): void => { this.#server.off("listening", onListening); @@ -150,8 +158,17 @@ export class MiniAppServer { this.#healthTimer.unref(); this.options.logger.info("Wirebot HTTP server listening", { host: this.options.host, - port: this.options.port, + port: this.serverUrl().port, }); + return this.serverUrl(); + } + + private serverUrl(): URL { + const address = this.#server.address(); + if (address === null || typeof address === "string") + throw new Error("HTTP server is not listening"); + const host = address.address.includes(":") ? `[${address.address}]` : address.address; + return new URL(`http://${host}:${address.port}`); } public async stop(): Promise { @@ -175,8 +192,46 @@ export class MiniAppServer { return; } + if (url.pathname === "/api/auth/exchange") { + this.requireBrowserRequest(request); + if (request.method !== "POST") { + this.methodNotAllowed(response, "POST"); + return; + } + const { token } = z + .strictObject({ token: z.string().max(128) }) + .parse(await this.readJson(request)); + const session = await this.requireBrowserAuth().exchange(token); + this.options.browserAuth?.revoke(this.sessionCookie(request)); + this.setSessionCookie(response, session); + this.sendJson(response, 200, { authenticated: true }); + return; + } + + if (url.pathname === "/api/auth/session") { + if (request.method !== "GET") { + this.methodNotAllowed(response, "GET"); + return; + } + const principal = await this.authenticate(request); + this.sendJson(response, 200, { provider: principal.owner.provider }); + return; + } + + if (url.pathname === "/api/auth/logout") { + this.requireBrowserRequest(request); + if (request.method !== "POST") { + this.methodNotAllowed(response, "POST"); + return; + } + this.options.browserAuth?.revoke(this.sessionCookie(request)); + this.setSessionCookie(response, ""); + this.sendJson(response, 200, { authenticated: false }); + return; + } + if (url.pathname === "/api/config/validate") { - this.authenticate(request); + await this.authenticate(request); if (request.method === "POST") { const input = await this.readJson(request); this.sendJson(response, 200, await this.options.configService.validate(input)); @@ -187,7 +242,7 @@ export class MiniAppServer { } if (url.pathname === "/api/config") { - this.authenticate(request); + await this.authenticate(request); if (request.method === "GET") { const snapshot = await this.options.configService.read(); this.sendJson(response, 200, { @@ -231,7 +286,7 @@ export class MiniAppServer { } if (url.pathname === "/api/skills") { - this.authenticate(request); + await this.authenticate(request); if (request.method === "GET") { this.sendJson(response, 200, { skills: this.options.runtime.skills() }); return; @@ -241,7 +296,7 @@ export class MiniAppServer { } if (url.pathname === "/api/usage") { - this.authenticate(request); + await this.authenticate(request); if (request.method === "GET") { this.sendJson(response, 200, await this.options.runtime.usageLimits()); return; @@ -251,7 +306,7 @@ export class MiniAppServer { } if (url.pathname === "/api/usage/reset") { - this.authenticate(request); + await this.authenticate(request); if (request.method === "POST") { const input = applyBankedResetSchema.parse(await this.readJson(request)); const outcome = await this.options.runtime.applyBankedReset( @@ -266,7 +321,7 @@ export class MiniAppServer { } if (url.pathname === "/api/skills/resource") { - this.authenticate(request); + await this.authenticate(request); if (request.method === "GET") { const skill = url.searchParams.get("skill"); if (skill === null || skill.length === 0) { @@ -281,9 +336,8 @@ export class MiniAppServer { } if (url.pathname === "/api/schedules" || url.pathname.startsWith("/api/schedules/")) { - const user = this.authenticate(request); + const scope = await this.authenticate(request); const scheduledRuns = this.requireScheduledRuns(); - const scope = telegramScheduleScope(user.id); if (url.pathname === "/api/schedules") { if (request.method === "GET") { this.sendJson(response, 200, { schedules: scheduledRuns.listForOwner(scope.owner) }); @@ -323,7 +377,7 @@ export class MiniAppServer { } if (url.pathname === "/api/runtime/reload" || url.pathname === "/api/runtime/restart") { - this.authenticate(request); + await this.authenticate(request); if (request.method === "POST") { if (url.pathname.endsWith("/reload")) await this.options.runtime.reload(); else await this.options.runtime.restart(); @@ -340,7 +394,7 @@ export class MiniAppServer { } const asset = staticAssets.get(url.pathname); - if (asset !== undefined && this.options.telegramAuth !== undefined) { + if (asset !== undefined) { await this.sendAsset(response, request.method, asset[0], asset[1]); return; } @@ -348,18 +402,62 @@ export class MiniAppServer { this.sendError(response, 404, "Not found"); } - private authenticate(request: IncomingMessage): TelegramInitDataUser { - const telegram = this.options.telegramAuth; - if (telegram === undefined) throw new HttpError(404, "Not found"); + private async authenticate(request: IncomingMessage): Promise { const authorization = request.headers.authorization; - if (authorization === undefined || !authorization.toLowerCase().startsWith("tma ")) { - throw new BridgeError("Telegram authorization is required", "MINIAPP_UNAUTHORIZED"); + if (authorization !== undefined) { + const telegram = this.options.telegramAuth; + if (telegram === undefined || !authorization.toLowerCase().startsWith("tma ")) { + throw new BridgeError("Invalid authorization", "MINIAPP_UNAUTHORIZED"); + } + const user = validateTelegramInitData(authorization.slice(4), { + botToken: telegram.botToken, + allowedUserIds: telegram.allowedUserIds, + maxAgeSeconds: MAX_AUTH_AGE_SECONDS, + }); + return telegramScheduleScope(user.id); } - return validateTelegramInitData(authorization.slice(4), { - botToken: telegram.botToken, - allowedUserIds: telegram.allowedUserIds, - maxAgeSeconds: MAX_AUTH_AGE_SECONDS, - }); + this.requireBrowserRequest(request); + return this.requireBrowserAuth().authenticate(this.sessionCookie(request)); + } + + private requireBrowserAuth(): BrowserAuth { + if (this.options.browserAuth === undefined) { + throw new BridgeError("Browser sign-in is unavailable", "MINIAPP_UNAUTHORIZED"); + } + return this.options.browserAuth; + } + + private requireBrowserRequest(request: IncomingMessage): void { + // A custom header requires a CORS preflight. This server never grants CORS, + // so another site cannot exchange credentials or mutate a cookie session. + if ( + request.headers["x-wirebot-request"] !== "1" || + request.headers["sec-fetch-site"] === "cross-site" + ) { + throw new BridgeError("Open Wirebot to continue", "MINIAPP_UNAUTHORIZED"); + } + } + + private sessionCookie(request: IncomingMessage): string { + const prefix = `${this.cookieName()}=`; + return ( + request.headers.cookie + ?.split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)) + ?.slice(prefix.length) ?? "" + ); + } + + private cookieName(): string { + return this.options.secureCookies === false ? "wirebot_session" : "__Host-wirebot_session"; + } + + private setSessionCookie(response: ServerResponse, token: string): void { + response.setHeader( + "Set-Cookie", + `${this.cookieName()}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${token === "" ? 0 : sessionLifetimeMs / 1_000}${this.options.secureCookies === false ? "" : "; Secure"}`, + ); } private refreshCodexHealth(): void { @@ -505,11 +603,11 @@ export class MiniAppServer { if (error instanceof BridgeError) { if (error.code === "MINIAPP_UNAUTHORIZED") { response.setHeader("WWW-Authenticate", "tma"); - this.sendError(response, 401, error.message); + this.sendJson(response, 401, { error: error.message, code: error.code }); return; } if (error.code === "MINIAPP_FORBIDDEN") { - this.sendError(response, 403, error.message); + this.sendJson(response, 403, { error: error.message, code: error.code }); return; } if (error.code === "SKILL_NOT_FOUND") { diff --git a/src/miniapp/sign-in.tsx b/src/miniapp/sign-in.tsx new file mode 100644 index 0000000..258bac8 --- /dev/null +++ b/src/miniapp/sign-in.tsx @@ -0,0 +1,84 @@ +import { Terminal } from "lucide-react"; +import { type ReactElement, useState } from "react"; +import { Button } from "./ui.js"; + +export function SignIn({ + error, + busy, + onSignIn, +}: { + readonly error: string | undefined; + readonly busy: boolean; + readonly onSignIn: (token: string) => Promise; +}): ReactElement { + const [value, setValue] = useState(""); + const [inputError, setInputError] = useState(); + return ( +
+
+
+
+

WIREBOT

+

+ Your workspace, +
+ wherever you are. +

+

+ Manage Codex settings, explore skills, and keep scheduled work on track. +

+
+

Sign in through your bot

+

+ Send /wirebot web in a direct message to your Slack or Discord bot, or{" "} + /web in Telegram. Open the private link it replies with. +

+

Access is available to Wirebot admins. Links expire after 5 minutes and work once.

+
+
{ + event.preventDefault(); + let token = value.trim(); + try { + token = new URL(token).hash.slice("#login=".length); + } catch { + /* A raw token is also accepted. */ + } + if (!/^[A-Za-z0-9_-]{43}$/.test(token)) { + setInputError("Paste the complete sign-in link or token from your bot."); + return; + } + setInputError(undefined); + setValue(""); + void onSignIn(token); + }} + > + + setValue(event.target.value)} + placeholder="Paste your sign-in link" + disabled={busy} + /> + {(inputError ?? error) && ( +

+ {inputError ?? error} +

+ )} + +
+

+ Using Telegram? You can also open Settings directly inside the bot. +

+
+
+ ); +} diff --git a/src/miniapp/skills.tsx b/src/miniapp/skills.tsx index 7dfc83f..b7f5ebb 100644 --- a/src/miniapp/skills.tsx +++ b/src/miniapp/skills.tsx @@ -301,7 +301,7 @@ function renderFilePreview(file: SkillFile | undefined, error: string | undefine } return ( - This binary file can be browsed, but it cannot be previewed in the Mini App. + This binary file can be browsed, but it cannot be previewed here. ); } diff --git a/src/miniapp/styles.css b/src/miniapp/styles.css index 677501f..71b3564 100644 --- a/src/miniapp/styles.css +++ b/src/miniapp/styles.css @@ -24,6 +24,54 @@ } :root { + color-scheme: light; + --radius: 0.875rem; + --background: #f5f7fb; + --foreground: #172337; + --card: #ffffff; + --card-foreground: var(--foreground); + --primary: #285bd4; + --primary-foreground: #ffffff; + --secondary: #eaf0fa; + --secondary-foreground: #253b60; + --muted: #edf1f7; + --muted-foreground: #5d6c81; + --accent: #e9effb; + --accent-foreground: var(--foreground); + --destructive: #c32e3d; + --destructive-foreground: #ffffff; + --switch-thumb: #ffffff; + --border: #dce3ee; + --input: #c7d2e2; + --ring: var(--primary); + --section-header: #5d6c81; + --tabbar-background: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +:root[data-appearance="dark"] { + color-scheme: dark; + --background: #10141c; + --foreground: #e5ebf5; + --card: #191f2b; + --primary: #90b4ff; + --primary-foreground: #122544; + --secondary: #263248; + --secondary-foreground: #d9e5ff; + --muted: #242d3c; + --muted-foreground: #a0aec3; + --accent: #293650; + --destructive: #ff8793; + --destructive-foreground: #35151a; + --border: #303c50; + --input: #42516a; + --section-header: #a0aec3; + --tabbar-background: #191f2b; +} + +.telegramApp { color-scheme: light dark; --radius: 0.875rem; --background: var(--tg-theme-bg-color, #ffffff); @@ -59,7 +107,7 @@ text-rendering: optimizeLegibility; } -.appRoot.dark { +.telegramApp.dark { --destructive-foreground: var(--tg-theme-text-color, var(--primary-foreground)); --switch-thumb: var(--tg-theme-text-color, var(--primary-foreground)); } @@ -1486,3 +1534,256 @@ button { transition-duration: 0.01ms; } } + +/* Browser chrome also works at phone widths and with Safari's dynamic viewport. */ +.browserApp, +.signInRoot { + min-height: 100dvh; +} + +.browserHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + max-width: 720px; + margin: 0 auto; + padding: calc(12px + env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) 0 + max(16px, env(safe-area-inset-left)); + color: var(--muted-foreground); + font-size: 0.8rem; +} + +.browserHeader strong { + text-transform: capitalize; +} +.browserApp .page { + padding-left: max(16px, env(safe-area-inset-left)); + padding-right: max(16px, env(safe-area-inset-right)); +} +.browserApp input, +.browserApp select, +.browserApp textarea { + font-size: 16px; +} +.navBrand { + display: none; +} +.navItems a { + text-decoration: none; +} +.sessionError { + padding: 12px 24px; + color: var(--destructive); +} + +.signInRoot { + display: grid; + place-items: center; + padding: max(32px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) + max(32px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); + background: radial-gradient( + ellipse at 20% 0%, + color-mix(in srgb, var(--primary) 9%, transparent), + transparent 60% + ); +} + +.signInCard { + width: min(100%, 480px); + padding: 36px; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--card); + box-shadow: 0 18px 64px #1020400a; +} + +.signInMark { + display: grid; + place-items: center; + width: 52px; + height: 52px; + margin-bottom: 24px; + border-radius: 16px; + background: var(--primary); + color: var(--primary-foreground); +} +.eyebrow { + margin: 0 0 12px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.15em; + color: var(--muted-foreground); +} +.signInCard h1 { + margin: 0; + font-size: clamp(1.8rem, 5vw, 2.15rem); + line-height: 1.18; + letter-spacing: -0.035em; +} +.signInIntro { + margin: 16px 0 28px; + color: var(--muted-foreground); + line-height: 1.65; +} +.signInInstructions { + padding: 18px; + margin-bottom: 26px; + border-radius: 14px; + background: var(--background); +} +.signInInstructions h2 { + margin: 0 0 10px; + font-size: 0.95rem; +} +.signInInstructions p { + font-size: 0.85rem; + line-height: 1.65; + color: var(--muted-foreground); + margin: 10px 0 0; +} +.signInInstructions code { + white-space: nowrap; + color: var(--foreground); + font-size: 0.8rem; +} +.signInCard form { + display: grid; + gap: 12px; +} +.signInCard label { + font-size: 0.85rem; + font-weight: 600; +} +.signInCard input { + width: 100%; + min-width: 0; + min-height: 48px; + border: 1px solid var(--input); + border-radius: 12px; + padding: 12px; + background: var(--background); + color: var(--foreground); +} +.signInCard input:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} +.signInFootnote { + margin: 24px 0 0; + color: var(--muted-foreground); + font-size: 0.75rem; + line-height: 1.6; +} +.signInError { + color: var(--destructive); + font-size: 0.85rem; + margin: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 480px) { + .signInCard { + padding: 24px; + } +} + +@media (min-width: 960px) { + .browserApp.authenticatedApp { + padding-left: 240px; + } + .browserApp .page { + width: min(100%, 900px); + padding: 32px 40px 48px; + } + .browserApp .pageWithSaveDock { + padding-bottom: 128px; + } + .browserApp .browserHeader { + max-width: 900px; + padding: 20px 40px 0; + } + .browserApp .ui-tabbar { + width: 240px; + top: 0; + right: auto; + padding: 32px 16px; + border-top: 0; + border-right: 1px solid var(--border); + } + .browserApp .navBrand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px 36px; + font-size: 1.2rem; + font-weight: 700; + } + .browserApp .navBrand svg { + width: 32px; + height: 32px; + color: var(--primary); + } + .browserApp .navBrand small { + display: block; + margin-top: 3px; + font-size: 0.7rem; + font-weight: 400; + color: var(--muted-foreground); + } + .browserApp .navItems { + display: flex; + flex-direction: column; + gap: 8px; + } + .browserApp .navItems a { + flex-direction: row; + justify-content: flex-start; + gap: 12px; + padding: 14px 16px; + font-size: 0.9rem; + } + .browserApp .navItems a svg { + width: 20px; + height: 20px; + } + .browserApp .saveDock { + left: 240px; + bottom: 0; + padding: 16px 40px max(16px, env(safe-area-inset-bottom)); + } + .browserApp .saveDockInner { + width: min(100%, 820px); + } +} + +.browserApp .nativeSelect { + appearance: none; + padding-right: 36px; + background-image: + linear-gradient(45deg, transparent 50%, var(--muted-foreground) 50%), + linear-gradient(135deg, var(--muted-foreground) 50%, transparent 50%); + background-position: + calc(100% - 18px) 50%, + calc(100% - 13px) 50%; + background-size: 5px 5px; + background-repeat: no-repeat; +} + +.browserApp .ui-tabbar { + background: var(--tabbar-background); +} + +/* Safari's keyboard changes the visual viewport without resizing the layout viewport. */ +.browserApp .fullscreenEditor, +.browserApp .resetDialogBackdrop { + top: var(--browser-viewport-top, 0px); + bottom: auto; + height: var(--browser-viewport-height, 100dvh); + min-height: 0; +} + +.browserApp .resetDialog { + max-height: 100%; + overflow-y: auto; +} diff --git a/src/miniapp/telegram.ts b/src/miniapp/telegram.ts index 6186ced..5197b07 100644 --- a/src/miniapp/telegram.ts +++ b/src/miniapp/telegram.ts @@ -32,7 +32,8 @@ declare global { } } -export const webApp = window.Telegram?.WebApp; +const telegramApp = window.Telegram?.WebApp; +export const webApp = telegramApp?.initData ? telegramApp : undefined; export const telegramReady = webApp !== undefined && webApp.initData.length > 0; export const nativeTelegramNavigation = telegramReady && webApp?.BackButton !== undefined; diff --git a/src/miniapp/ui.tsx b/src/miniapp/ui.tsx index 78ba3d4..bda098f 100644 --- a/src/miniapp/ui.tsx +++ b/src/miniapp/ui.tsx @@ -1,6 +1,7 @@ import * as SwitchPrimitive from "@radix-ui/react-switch"; import { LoaderCircle } from "lucide-react"; import { + type AnchorHTMLAttributes, type ButtonHTMLAttributes, type ElementType, forwardRef, @@ -275,15 +276,16 @@ export function Headline({ } interface TabbarProps extends HTMLAttributes { + readonly brand?: ReactNode; readonly children?: ReactNode; } -interface TabbarItemProps extends ButtonHTMLAttributes { +interface TabbarItemProps extends AnchorHTMLAttributes { readonly selected: boolean; readonly text: string; } -function TabbarRoot({ className, children, ...props }: TabbarProps): ReactElement { +function TabbarRoot({ className, children, brand, ...props }: TabbarProps): ReactElement { return ( ); } @@ -305,8 +308,7 @@ function TabbarItem({ ...props }: TabbarItemProps): ReactElement { return ( - + ); } diff --git a/src/miniapp/usage-section.tsx b/src/miniapp/usage-section.tsx index 67b5412..ba81e6c 100644 --- a/src/miniapp/usage-section.tsx +++ b/src/miniapp/usage-section.tsx @@ -12,7 +12,7 @@ import type { import { requestApplyBankedReset, requestUsage } from "./api.js"; import { ConfirmDialog } from "./dialogs.js"; import { isDefined, messageOf } from "./shared.js"; -import { notifyHaptic, telegramReady } from "./telegram.js"; +import { notifyHaptic } from "./telegram.js"; import { Button, Caption, Section, Spinner } from "./ui.js"; interface ResetConfirmation { @@ -43,7 +43,6 @@ export function UsageSection(): ReactElement { }, []); useEffect(() => { - if (!telegramReady) return; void refreshUsage(); const timer = window.setInterval(() => void refreshUsage(false), 60_000); return () => window.clearInterval(timer); diff --git a/test/browser-auth.test.ts b/test/browser-auth.test.ts new file mode 100644 index 0000000..89df367 --- /dev/null +++ b/test/browser-auth.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { + type AppPrincipal, + BrowserAuth, + loginLifetimeMs, + sessionLifetimeMs, +} from "../src/miniapp/browser-auth.js"; + +function principal(provider = "discord", id = "123"): AppPrincipal { + return { + owner: { provider, resource: "user", id }, + conversation: { provider, resource: "conversation", id: `${provider}:dm` }, + deliveryTarget: { provider, resource: "destination", id: "dm" }, + }; +} + +describe("Browser credentials", () => { + test("links are one-use, including simultaneous exchanges", async () => { + const auth = new BrowserAuth(() => true); + const link = await auth.issue(principal()); + const exchanges = await Promise.allSettled([auth.exchange(link), auth.exchange(link)]); + expect(exchanges.filter((value) => value.status === "fulfilled")).toHaveLength(1); + expect(exchanges.filter((value) => value.status === "rejected")).toHaveLength(1); + const success = exchanges.find((value) => value.status === "fulfilled"); + if (success?.status !== "fulfilled") throw new Error("No successful exchange"); + expect(success.value).not.toBe(link); + expect(await auth.authenticate(success.value)).toEqual(principal()); + await expect(auth.authenticate(link)).rejects.toThrow(); + await expect(auth.exchange(success.value)).rejects.toThrow(); + }); + + test("expires both links and sessions at their deadline", async () => { + let now = 0; + const auth = new BrowserAuth( + () => true, + () => now, + ); + const expired = await auth.issue(principal()); + now = loginLifetimeMs; + await expect(auth.exchange(expired)).rejects.toThrow(); + const session = await auth.exchange(await auth.issue(principal())); + now += sessionLifetimeMs - 1; + expect(await auth.authenticate(session)).toEqual(principal()); + now += 1; + await expect(auth.authenticate(session)).rejects.toThrow(); + }); + + test("rechecks admin permission at issuance, redemption, and on each request", async () => { + let admin = false; + const auth = new BrowserAuth(() => admin); + await expect(auth.issue(principal())).rejects.toThrow("admins"); + admin = true; + const link = await auth.issue(principal()); + const session = await auth.exchange(await auth.issue(principal("slack"))); + admin = false; + await expect(auth.exchange(link)).rejects.toThrow("admins"); + await expect(auth.authenticate(session)).rejects.toThrow("admins"); + }); + + test("replaces only the requesting admin's unused link and preserves provider scope", async () => { + const auth = new BrowserAuth(() => true); + const old = await auth.issue(principal()); + const slack = await auth.issue(principal("slack")); + const fresh = await auth.issue(principal()); + await expect(auth.exchange(old)).rejects.toThrow(); + expect(await auth.authenticate(await auth.exchange(slack))).toEqual(principal("slack")); + expect(await auth.authenticate(await auth.exchange(fresh))).toEqual(principal()); + }); + + test("logout and process restarts revoke sessions", async () => { + const auth = new BrowserAuth(() => true); + const session = await auth.exchange(await auth.issue(principal())); + await expect(new BrowserAuth(() => true).authenticate(session)).rejects.toThrow(); + auth.revoke(session); + await expect(auth.authenticate(session)).rejects.toThrow(); + }); + + test("rejects malformed credentials and mixed provider scopes", async () => { + const auth = new BrowserAuth(() => true); + for (const token of ["", "a".repeat(42), "!".repeat(43), "a".repeat(10000)]) { + await expect(auth.exchange(token)).rejects.toThrow(); + } + await expect( + auth.issue({ ...principal(), deliveryTarget: principal("slack").deliveryTarget }), + ).rejects.toThrow(); + }); +}); diff --git a/test/channel-admin.test.ts b/test/channel-admin.test.ts new file mode 100644 index 0000000..23ac896 --- /dev/null +++ b/test/channel-admin.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { DiscordChannel } from "../src/channels/discord/channel.js"; +import { SlackChannel } from "../src/channels/slack/channel.js"; +import { TelegramChannel } from "../src/channels/telegram/channel.js"; +import { Logger } from "../src/shared/logger.js"; + +const logger = new Logger("error"); +describe("Connector browser admin policy", () => { + for (const provider of ["discord", "slack"] as const) { + test(`${provider} requires both ordinary access and the configured admin list`, async () => { + for (const adminUserIds of [undefined, new Set(["admin", "outsider"])]) { + const config = { + botToken: "test-bot-token", + allowedUserIds: new Set(["admin", "member"]), + adminUserIds, + }; + const channel = + provider === "discord" + ? new DiscordChannel(config, logger) + : new SlackChannel( + { + ...config, + botToken: "xoxb-test", + appToken: "xapp-test", + allowAllWorkspaceMembers: false, + }, + "/tmp/unused", + logger, + ); + try { + expect(await channel.isAuthorizedAdmin({ provider, resource: "user", id: "admin" })).toBe( + true, + ); + expect( + await channel.isAuthorizedAdmin({ provider, resource: "user", id: "member" }), + ).toBe(adminUserIds === undefined); + expect( + await channel.isAuthorizedAdmin({ provider, resource: "user", id: "outsider" }), + ).toBe(false); + expect( + await channel.isAuthorizedAdmin({ + provider: "telegram", + resource: "user", + id: "admin", + }), + ).toBe(false); + } finally { + await channel.stop(); + } + } + }); + } + test("Telegram treats each allowlisted user as an admin", async () => { + const channel = new TelegramChannel( + "123456:TEST_BOT_TOKEN", + "https://api.telegram.org", + new Set([42]), + 30, + "/tmp/unused", + logger, + ); + try { + expect( + await channel.isAuthorizedAdmin({ provider: "telegram", resource: "user", id: "42" }), + ).toBe(true); + expect( + await channel.isAuthorizedAdmin({ provider: "telegram", resource: "user", id: "43" }), + ).toBe(false); + } finally { + await channel.stop(); + } + }); +}); diff --git a/test/fixtures/web-app.ts b/test/fixtures/web-app.ts new file mode 100644 index 0000000..628d22e --- /dev/null +++ b/test/fixtures/web-app.ts @@ -0,0 +1,162 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AutomationManagementError } from "../../src/automations/engine.js"; +import type { EditableConfigSnapshot } from "../../src/codex/config-service.js"; +import type { CodexRuntimeStatus } from "../../src/codex/runtime-service.js"; +import { type AppPrincipal, BrowserAuth } from "../../src/miniapp/browser-auth.js"; +import { MiniAppServer, type MiniAppServerOptions } from "../../src/miniapp/server.js"; +import { Logger } from "../../src/shared/logger.js"; + +export const testPrincipal: AppPrincipal = { + owner: { provider: "slack", resource: "user", id: "U_ADMIN" }, + conversation: { provider: "slack", resource: "conversation", id: "slack:D_ADMIN" }, + deliveryTarget: { provider: "slack", resource: "destination", id: "private-dm-target" }, +}; +export const snapshot: EditableConfigSnapshot = { + version: "test-version", + values: { + model: "test-model", + model_provider: null, + approval_policy: "on-request", + approvals_reviewer: "user", + sandbox_mode: "workspace-write", + default_permissions: null, + web_search: "live", + model_reasoning_effort: null, + model_reasoning_summary: null, + model_verbosity: null, + service_tier: null, + personality: null, + windows_sandbox: null, + shell_environment_include_only: null, + features: {}, + }, + capabilities: { + platform: "linux", + models: [ + { + model: "test-model", + displayName: "Test model", + description: "A local fixture model", + supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Balanced" }], + defaultReasoningEffort: "medium", + serviceTiers: [], + defaultServiceTier: null, + isDefault: true, + }, + ], + modelProviders: [{ id: "openai", displayName: "OpenAI", description: "", allowed: true }], + permissionProfiles: [], + features: [], + requirements: null, + }, + validation: { valid: true, issues: [] }, +}; + +/** Exercises the real HTTP server; only the Codex/messenger services are local fakes. */ +export async function startTestApp(assetDirectory?: string, includeTelegram = false) { + let admin = true; + const auth = new BrowserAuth( + (owner) => admin && (owner.provider === "slack" || owner.provider === "discord"), + ); + const assets = assetDirectory ?? (await mkdtemp(join(tmpdir(), "wirebot-browser-test-"))); + const ownedDirectory = assetDirectory === undefined ? assets : undefined; + if (ownedDirectory !== undefined) { + await Promise.all( + ["index.html", "app.js", "app.css"].map((name) => + Bun.write( + join(assets, name), + name === "index.html" ? "Wirebot" : "", + ), + ), + ); + } + const scheduleScopes: unknown[][] = []; + const status: CodexRuntimeStatus = { + state: "ready", + restartRequired: false, + lastError: null, + lastAppliedAt: null, + configPath: "/test/config.toml", + }; + const runtime = { + status: () => status, + usageLimits: async () => ({ + weekly: { remainingPercent: 78, resetsAt: null }, + fiveHour: { remainingPercent: 94, resetsAt: null }, + bankedResets: null, + }), + skills: () => [ + { + name: "workspace-guide", + description: "Work with your project files and development tools.", + }, + ], + browseSkill: async () => { + throw new Error("Not used"); + }, + afterConfigWrite: async () => status, + reload: async () => status, + restart: async () => status, + applyBankedReset: async () => "nothingToReset" as const, + }; + const server = new MiniAppServer({ + host: "127.0.0.1", + port: 0, + browserAuth: auth, + assetDirectory: assets, + ...(includeTelegram + ? { telegramAuth: { botToken: "123456:TEST_BOT_TOKEN", allowedUserIds: new Set([42]) } } + : {}), + codex: { account: async () => ({ account: null, requiresOpenaiAuth: false }) }, + runtime, + configService: { + read: async () => snapshot, + validate: async () => ({ valid: true, issues: [] }), + update: async () => ({ + status: "ok", + version: "saved", + filePath: "/test/config.toml", + overriddenMetadata: null, + }), + }, + settings: { + read: () => ({ remoteClientContext: true }), + update: async () => ({ remoteClientContext: true }), + }, + scheduledRuns: { + listForOwner: (owner) => { + scheduleScopes.push([owner]); + return []; + }, + createForOwner: async (...args) => { + scheduleScopes.push(args); + throw new AutomationManagementError("invalid", "Captured scope"); + }, + updateForOwner: async (...args) => { + scheduleScopes.push(args); + throw new AutomationManagementError("not_found", "Schedule not found"); + }, + deleteForOwner: async (...args) => { + scheduleScopes.push(args); + throw new AutomationManagementError("not_found", "Schedule not found"); + }, + }, + logger: new Logger("error"), + } satisfies MiniAppServerOptions); + const url = await server.start(); + return { + server, + url, + auth, + scheduleScopes, + setAdmin: (value: boolean) => { + admin = value; + }, + close: async () => { + await server.stop(); + if (ownedDirectory !== undefined) await rm(ownedDirectory, { recursive: true, force: true }); + }, + }; +} diff --git a/test/miniapp-browser.test.ts b/test/miniapp-browser.test.ts new file mode 100644 index 0000000..cfd7ff7 --- /dev/null +++ b/test/miniapp-browser.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHmac } from "node:crypto"; +import { startTestApp, testPrincipal } from "./fixtures/web-app.js"; + +let app: Awaited>; +afterEach(async () => { + await app?.close(); +}); +const browserHeaders = { "X-Wirebot-Request": "1", "Content-Type": "application/json" }; +async function request(path: string, init?: RequestInit) { + return fetch(new URL(path, app.url), init); +} +async function signIn() { + const token = await app.auth.issue(testPrincipal); + const response = await request("/api/auth/exchange", { + method: "POST", + headers: browserHeaders, + body: JSON.stringify({ token }), + }); + expect(response.status).toBe(200); + return { token, cookie: response.headers.get("set-cookie") as string }; +} + +describe("Browser HTTP app", () => { + test("serves browser deep links and legacy miniapp URLs without Telegram configured", async () => { + app = await startTestApp(); + for (const path of [ + "/", + "/app", + "/app/", + "/app/settings", + "/app/skills", + "/app/schedules", + "/miniapp", + "/miniapp/", + "/miniapp/app.js", + "/miniapp/app.css", + ]) { + expect((await request(path)).status).toBe(200); + expect((await request(path, { method: "HEAD" })).status).toBe(200); + } + expect((await request("/app/unknown")).status).toBe(404); + expect((await request("/api/config")).status).toBe(401); + expect((await request("/healthz")).status).toBe(200); + }); + + test("exchanges a link for a protected cookie, restores the session, and revokes it on logout", async () => { + app = await startTestApp(); + const { token, cookie } = await signIn(); + expect(cookie).toContain("__Host-wirebot_session="); + for (const flag of ["HttpOnly", "SameSite=Strict", "Secure", "Path=/", "Max-Age=43200"]) + expect(cookie).toContain(flag); + const headers = { ...browserHeaders, Cookie: cookie.split(";")[0] as string }; + expect(await (await request("/api/auth/session", { headers })).json()).toEqual({ + provider: "slack", + }); + expect((await request("/api/config", { headers })).status).toBe(200); + expect( + ( + await request("/api/auth/exchange", { + method: "POST", + headers, + body: JSON.stringify({ token }), + }) + ).status, + ).toBe(401); + const logout = await request("/api/auth/logout", { method: "POST", headers }); + expect(logout.headers.get("set-cookie")).toContain("Max-Age=0"); + expect((await request("/api/config", { headers })).status).toBe(401); + }); + + test("rejects CSRF, simple cross-origin requests, and unauthorized users", async () => { + app = await startTestApp(); + const { cookie } = await signIn(); + expect( + (await request("/api/runtime/restart", { method: "POST", headers: { Cookie: cookie } })) + .status, + ).toBe(401); + expect( + ( + await request("/api/config", { + headers: { ...browserHeaders, Cookie: cookie, "Sec-Fetch-Site": "cross-site" }, + }) + ).status, + ).toBe(401); + const preflight = await request("/api/auth/exchange", { + method: "OPTIONS", + headers: { + Origin: "https://attacker.example", + "Access-Control-Request-Headers": "X-Wirebot-Request", + }, + }); + expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); + app.setAdmin(false); + expect( + (await request("/api/config", { headers: { ...browserHeaders, Cookie: cookie } })).status, + ).toBe(403); + }); + + test("carries browser ownership and the original delivery destination into all schedule operations", async () => { + app = await startTestApp(); + const { cookie } = await signIn(); + const headers = { ...browserHeaders, Cookie: cookie }; + await request("/api/schedules", { headers }); + await request("/api/schedules", { + method: "POST", + headers, + body: JSON.stringify({ name: "Test" }), + }); + await request("/api/schedules/someone-elses-id", { method: "PATCH", headers, body: "{}" }); + await request("/api/schedules/someone-elses-id", { method: "DELETE", headers }); + expect(app.scheduleScopes.map((args) => args[0])).toEqual(Array(4).fill(testPrincipal.owner)); + expect(app.scheduleScopes[1]?.slice(0, 3)).toEqual([ + testPrincipal.owner, + testPrincipal.conversation, + testPrincipal.deliveryTarget, + ]); + const discord = { + owner: { ...testPrincipal.owner, provider: "discord" }, + conversation: { ...testPrincipal.conversation, provider: "discord" }, + deliveryTarget: { ...testPrincipal.deliveryTarget, provider: "discord" }, + }; + const session = await app.auth.exchange(await app.auth.issue(discord)); + await request("/api/schedules", { + headers: { ...browserHeaders, Cookie: `__Host-wirebot_session=${session}` }, + }); + expect(app.scheduleScopes.at(-1)?.[0]).toEqual(discord.owner); + }); + + test("keeps Telegram signatures, expiry, allowlists, and private schedule ownership", async () => { + app = await startTestApp(undefined, true); + function signed(userId: number, age = 0) { + const data = new URLSearchParams({ + auth_date: String(Math.floor(Date.now() / 1000) - age), + user: JSON.stringify({ id: userId }), + }); + const secret = createHmac("sha256", "WebAppData").update("123456:TEST_BOT_TOKEN").digest(); + const signature = createHmac("sha256", secret) + .update( + [...data] + .map(([key, value]) => `${key}=${value}`) + .sort() + .join("\n"), + ) + .digest("hex"); + data.set("hash", signature); + return { Authorization: `tma ${data}` }; + } + expect((await request("/api/config", { headers: signed(42) })).status).toBe(200); + expect((await request("/api/config", { headers: signed(43) })).status).toBe(403); + expect((await request("/api/config", { headers: signed(42, 3601) })).status).toBe(401); + expect( + (await request("/api/config", { headers: { Authorization: "tma forged" } })).status, + ).toBe(401); + await request("/api/schedules", { headers: signed(42) }); + expect(app.scheduleScopes[0]?.[0]).toEqual({ + provider: "telegram", + resource: "user", + id: "42", + }); + }); +}); diff --git a/test/web-command.test.ts b/test/web-command.test.ts new file mode 100644 index 0000000..ec37f0b --- /dev/null +++ b/test/web-command.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { ScheduledRunsEngine } from "../src/automations/engine.js"; +import type { CodexService } from "../src/codex/service.js"; +import { CodexBridge } from "../src/core/bridge.js"; +import type { InboundMessage, SendOptions } from "../src/core/channel.js"; +import { BrowserAuth } from "../src/miniapp/browser-auth.js"; +import { Logger } from "../src/shared/logger.js"; + +function setup(provider: string, command: string, admin = true, isPrivate = true) { + const auth = new BrowserAuth(() => true); + const issue = mock(auth.issue.bind(auth)); + auth.issue = issue; + const sendText = mock(async (_text: string, _options?: SendOptions) => {}); + const bridge = new CodexBridge( + { onLoginCompleted: () => () => {} } as unknown as CodexService, + "https://wirebot.example", + new Logger("error"), + { + status: () => { + throw new Error("unused"); + }, + reload: async () => {}, + restart: async () => {}, + }, + {} as ScheduledRunsEngine, + auth, + ); + const message: InboundMessage = { + id: "msg", + address: { + channel: provider, + key: `${provider}:dm`, + isPrivate, + isGuest: false, + deliveryTarget: { provider, resource: "destination", id: "dm" }, + }, + sender: { id: "admin", displayName: "Admin" }, + text: `/${command}`, + command: { name: command, args: "" }, + attachments: [], + isAdmin: admin, + responder: { + sendText, + createStream: () => { + throw new Error("unused"); + }, + askChoice: async () => "decline", + }, + }; + return { bridge, message, issue, sendText }; +} + +describe("Browser entry commands", () => { + for (const provider of ["telegram", "slack", "discord"]) { + test(`${provider} issues /web only for an admin in a private bot chat`, async () => { + for (const [admin, isPrivate] of [ + [false, true], + [true, false], + [true, true], + ]) { + const s = setup(provider, "web", admin, isPrivate); + await s.bridge.handleMessage(s.message); + expect(s.issue.mock.calls.length).toBe(admin && isPrivate ? 1 : 0); + if (admin && isPrivate) + expect(s.sendText.mock.calls[0]?.[1]?.button?.url).toMatch( + /^https:\/\/wirebot.example\/app#login=[A-Za-z0-9_-]{43}$/, + ); + } + }); + } + test("Telegram config keeps a Mini App button; Slack and Discord config issue browser links", async () => { + for (const provider of ["telegram", "slack", "discord"]) { + const s = setup(provider, "config"); + await s.bridge.handleMessage(s.message); + expect(s.sendText.mock.calls[0]?.[1]?.button?.kind).toBe( + provider === "telegram" ? "webApp" : "url", + ); + } + }); +}); From 70c4f1305118475750f23178fc6f7827394a9a02 Mon Sep 17 00:00:00 2001 From: sadfun Date: Sat, 5 Sep 2026 23:16:17 +0200 Subject: [PATCH 2/3] Simplify browser sign-in UI and centralize API authentication --- README.md | 2 +- src/miniapp/app.tsx | 38 +++++++----- src/miniapp/browser-auth.ts | 8 +-- src/miniapp/server.ts | 73 +++++++++------------- src/miniapp/sign-in.tsx | 84 -------------------------- src/miniapp/styles.css | 114 +---------------------------------- test/miniapp-browser.test.ts | 18 +++++- 7 files changed, 74 insertions(+), 263 deletions(-) delete mode 100644 src/miniapp/sign-in.tsx diff --git a/README.md b/README.md index 271f3b0..5e9ea40 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ Wirebot's design rule is to use Codex's native app-server behavior instead of bu Open `https://your-wirebot-origin/app` in a normal browser. Settings, Skills, and Schedules also have bookmarkable URLs at `/app/settings`, `/app/skills`, and `/app/schedules`; `/` and the existing `/miniapp` URL work too. Desktop browsers use a left sidebar, while phones use bottom navigation. Browser colors follow the system light/dark preference, independently of Telegram. Mobile layouts support safe areas, dynamic viewport height, readable form controls, and pinch zoom. -For browser sign-in, send `/wirebot web` (or `/wirebot config`) in a private Slack or Discord bot chat; Telegram users can send `/web`. The bot replies with a private, one-use link that expires in 5 minutes. Open it in your preferred browser, or paste it into the app's sign-in screen on another device. Requesting a new link invalidates your previous unused link. After signing in, ordinary URLs work for 12 hours, including across reloads and new tabs. **Sign out** revokes that browser session; restarting Wirebot invalidates all links and sessions. +For browser sign-in, send `/wirebot web` (or `/wirebot config`) in a private Slack or Discord bot chat; Telegram users can send `/web`. The bot replies with a private, one-use link that expires in 5 minutes. Open it in your preferred browser. Requesting a new link invalidates your previous unused link. After signing in, ordinary URLs work for 12 hours, including across reloads and new tabs. **Sign out** revokes that browser session; restarting Wirebot invalidates all links and sessions. Browser access follows the connector's bot-admin policy, not Slack workspace or Discord server roles: `SLACK_ADMIN_USER_IDS` and `DISCORD_ADMIN_USER_IDS` restrict access when configured, and otherwise every authorized user is an admin. All Telegram allowlisted users are admins. The server rechecks authorization when issuing links, exchanging them, and using a session (Slack workspace membership uses its existing 10-minute cache). Link secrets live in URL fragments, are removed from the address bar on load, and are exchanged for Secure, HttpOnly, SameSite cookies. Serve the public app over HTTPS and keep login links private. Browser auth uses no Telegram SDK, localStorage, or third-party authentication service. diff --git a/src/miniapp/app.tsx b/src/miniapp/app.tsx index 743238f..40ad421 100644 --- a/src/miniapp/app.tsx +++ b/src/miniapp/app.tsx @@ -12,7 +12,6 @@ import { import { SchedulesManager } from "./schedules.js"; import { SettingsForm } from "./settings-form.js"; import { messageOf, useAsync } from "./shared.js"; -import { SignIn } from "./sign-in.js"; import { SkillsBrowser } from "./skills.js"; import { navigateWithUnsavedGuard, telegramReady, webApp } from "./telegram.js"; import { AppRoot, Button, Placeholder, Spinner, Tabbar } from "./ui.js"; @@ -68,7 +67,6 @@ export function SettingsApp({ loginToken }: { readonly loginToken: string | null useEffect(() => { document.documentElement.dataset.appearance = appearance; - document.documentElement.dataset.host = telegramReady ? "telegram" : "browser"; }, [appearance]); useEffect(() => { @@ -153,18 +151,6 @@ export function SettingsApp({ loginToken }: { readonly loginToken: string | null if (snapshotLoad.value !== undefined) setSnapshot(snapshotLoad.value); }, [snapshotLoad.value]); - const signIn = async (token: string): Promise => { - setAuthBusy(true); - setAuthError(undefined); - try { - await exchangeLogin(token); - setSession(await requestSession()); - } catch (error) { - setAuthError(messageOf(error)); - } finally { - setAuthBusy(false); - } - }; const signOut = (): void => navigateWithUnsavedGuard(() => { setAuthBusy(true); @@ -199,7 +185,29 @@ export function SettingsApp({ loginToken }: { readonly loginToken: string | null
) : session === null ? ( - +
+ Sign in to Wirebot} + description={ +
+

Manage Codex settings, skills, and schedules through your bot.

+

+ Send /wirebot web in a direct message to your Slack or Discord bot, + or /web in Telegram. Open the private link it replies with. +

+

Admin access only. Links work once and expire after 5 minutes.

+ {authError && ( +

+ {authError} +

+ )} +
+ } + > +
+
) : ( <> {!telegramReady && ( diff --git a/src/miniapp/browser-auth.ts b/src/miniapp/browser-auth.ts index 9a6b122..c1a7f25 100644 --- a/src/miniapp/browser-auth.ts +++ b/src/miniapp/browser-auth.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import type { ProviderReference } from "../core/channel.js"; +import { type ProviderReference, sameReference } from "../core/channel.js"; import { BridgeError } from "../shared/errors.js"; /** Provider-owned identity and destination, shared by both authentication methods. */ @@ -37,7 +37,7 @@ export class BrowserAuth { await this.requireAdmin(principal); // Only the latest unused link for this admin remains valid. for (const [key, grant] of this.#links) { - if (sameOwner(grant.principal, principal)) this.#links.delete(key); + if (sameReference(grant.principal.owner, principal.owner)) this.#links.delete(key); } return this.store(this.#links, principal, loginLifetimeMs); } @@ -108,10 +108,6 @@ function digest(token: string): string { return createHash("sha256").update(token).digest("hex"); } -function sameOwner(left: AppPrincipal, right: AppPrincipal): boolean { - return left.owner.provider === right.owner.provider && left.owner.id === right.owner.id; -} - function unauthorized(): BridgeError { return new BridgeError( "This sign-in has expired or was already used. Request a new link from the bot.", diff --git a/src/miniapp/server.ts b/src/miniapp/server.ts index 78173bc..4629c4b 100644 --- a/src/miniapp/server.ts +++ b/src/miniapp/server.ts @@ -18,7 +18,6 @@ import { CodexRpcError } from "../codex/rpc.js"; import type { CodexRuntimeService } from "../codex/runtime-service.js"; import type { CodexService } from "../codex/service.js"; import { SkillBrowserError } from "../codex/skill-browser.js"; -import type { ProviderReference } from "../core/channel.js"; import type { WirebotSettingsStore } from "../core/settings-store.js"; import type { GetAccountResponse } from "../generated/codex/v2/GetAccountResponse.js"; import { BridgeError, errorMessage } from "../shared/errors.js"; @@ -66,8 +65,6 @@ export interface MiniAppServerOptions { readonly allowedUserIds: ReadonlySet; }; readonly browserAuth?: BrowserAuth; - /** Disable only for a local HTTP development server. Public deployments use HTTPS. */ - readonly secureCookies?: boolean; readonly codex: Pick; readonly configService: Pick; readonly runtime: MiniAppRuntimeController; @@ -192,6 +189,22 @@ export class MiniAppServer { return; } + if (!url.pathname.startsWith("/api/")) { + if (request.method !== "GET" && request.method !== "HEAD") { + this.sendError(response, 405, "Method not allowed"); + return; + } + + const asset = staticAssets.get(url.pathname); + if (asset !== undefined) { + await this.sendAsset(response, request.method, asset[0], asset[1]); + return; + } + + this.sendError(response, 404, "Not found"); + return; + } + if (url.pathname === "/api/auth/exchange") { this.requireBrowserRequest(request); if (request.method !== "POST") { @@ -208,16 +221,6 @@ export class MiniAppServer { return; } - if (url.pathname === "/api/auth/session") { - if (request.method !== "GET") { - this.methodNotAllowed(response, "GET"); - return; - } - const principal = await this.authenticate(request); - this.sendJson(response, 200, { provider: principal.owner.provider }); - return; - } - if (url.pathname === "/api/auth/logout") { this.requireBrowserRequest(request); if (request.method !== "POST") { @@ -230,8 +233,18 @@ export class MiniAppServer { return; } + const scope = await this.authenticate(request); + + if (url.pathname === "/api/auth/session") { + if (request.method !== "GET") { + this.methodNotAllowed(response, "GET"); + return; + } + this.sendJson(response, 200, { provider: scope.owner.provider }); + return; + } + if (url.pathname === "/api/config/validate") { - await this.authenticate(request); if (request.method === "POST") { const input = await this.readJson(request); this.sendJson(response, 200, await this.options.configService.validate(input)); @@ -242,7 +255,6 @@ export class MiniAppServer { } if (url.pathname === "/api/config") { - await this.authenticate(request); if (request.method === "GET") { const snapshot = await this.options.configService.read(); this.sendJson(response, 200, { @@ -286,7 +298,6 @@ export class MiniAppServer { } if (url.pathname === "/api/skills") { - await this.authenticate(request); if (request.method === "GET") { this.sendJson(response, 200, { skills: this.options.runtime.skills() }); return; @@ -296,7 +307,6 @@ export class MiniAppServer { } if (url.pathname === "/api/usage") { - await this.authenticate(request); if (request.method === "GET") { this.sendJson(response, 200, await this.options.runtime.usageLimits()); return; @@ -306,7 +316,6 @@ export class MiniAppServer { } if (url.pathname === "/api/usage/reset") { - await this.authenticate(request); if (request.method === "POST") { const input = applyBankedResetSchema.parse(await this.readJson(request)); const outcome = await this.options.runtime.applyBankedReset( @@ -321,7 +330,6 @@ export class MiniAppServer { } if (url.pathname === "/api/skills/resource") { - await this.authenticate(request); if (request.method === "GET") { const skill = url.searchParams.get("skill"); if (skill === null || skill.length === 0) { @@ -336,7 +344,6 @@ export class MiniAppServer { } if (url.pathname === "/api/schedules" || url.pathname.startsWith("/api/schedules/")) { - const scope = await this.authenticate(request); const scheduledRuns = this.requireScheduledRuns(); if (url.pathname === "/api/schedules") { if (request.method === "GET") { @@ -377,7 +384,6 @@ export class MiniAppServer { } if (url.pathname === "/api/runtime/reload" || url.pathname === "/api/runtime/restart") { - await this.authenticate(request); if (request.method === "POST") { if (url.pathname.endsWith("/reload")) await this.options.runtime.reload(); else await this.options.runtime.restart(); @@ -388,17 +394,6 @@ export class MiniAppServer { return; } - if (request.method !== "GET" && request.method !== "HEAD") { - this.sendError(response, 405, "Method not allowed"); - return; - } - - const asset = staticAssets.get(url.pathname); - if (asset !== undefined) { - await this.sendAsset(response, request.method, asset[0], asset[1]); - return; - } - this.sendError(response, 404, "Not found"); } @@ -439,7 +434,7 @@ export class MiniAppServer { } private sessionCookie(request: IncomingMessage): string { - const prefix = `${this.cookieName()}=`; + const prefix = "__Host-wirebot_session="; return ( request.headers.cookie ?.split(";") @@ -449,14 +444,10 @@ export class MiniAppServer { ); } - private cookieName(): string { - return this.options.secureCookies === false ? "wirebot_session" : "__Host-wirebot_session"; - } - private setSessionCookie(response: ServerResponse, token: string): void { response.setHeader( "Set-Cookie", - `${this.cookieName()}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${token === "" ? 0 : sessionLifetimeMs / 1_000}${this.options.secureCookies === false ? "" : "; Secure"}`, + `__Host-wirebot_session=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${token === "" ? 0 : sessionLifetimeMs / 1_000}`, ); } @@ -638,11 +629,7 @@ function healthTimeout(): Promise { }); } -function telegramScheduleScope(userId: number): Readonly<{ - owner: ProviderReference; - conversation: ProviderReference; - deliveryTarget: ProviderReference; -}> { +function telegramScheduleScope(userId: number): AppPrincipal { return { owner: { provider: "telegram", resource: "user", id: String(userId) }, conversation: { diff --git a/src/miniapp/sign-in.tsx b/src/miniapp/sign-in.tsx deleted file mode 100644 index 258bac8..0000000 --- a/src/miniapp/sign-in.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Terminal } from "lucide-react"; -import { type ReactElement, useState } from "react"; -import { Button } from "./ui.js"; - -export function SignIn({ - error, - busy, - onSignIn, -}: { - readonly error: string | undefined; - readonly busy: boolean; - readonly onSignIn: (token: string) => Promise; -}): ReactElement { - const [value, setValue] = useState(""); - const [inputError, setInputError] = useState(); - return ( -
-
-
-
-

WIREBOT

-

- Your workspace, -
- wherever you are. -

-

- Manage Codex settings, explore skills, and keep scheduled work on track. -

-
-

Sign in through your bot

-

- Send /wirebot web in a direct message to your Slack or Discord bot, or{" "} - /web in Telegram. Open the private link it replies with. -

-

Access is available to Wirebot admins. Links expire after 5 minutes and work once.

-
-
{ - event.preventDefault(); - let token = value.trim(); - try { - token = new URL(token).hash.slice("#login=".length); - } catch { - /* A raw token is also accepted. */ - } - if (!/^[A-Za-z0-9_-]{43}$/.test(token)) { - setInputError("Paste the complete sign-in link or token from your bot."); - return; - } - setInputError(undefined); - setValue(""); - void onSignIn(token); - }} - > - - setValue(event.target.value)} - placeholder="Paste your sign-in link" - disabled={busy} - /> - {(inputError ?? error) && ( -

- {inputError ?? error} -

- )} - -
-

- Using Telegram? You can also open Settings directly inside the bot. -

-
-
- ); -} diff --git a/src/miniapp/styles.css b/src/miniapp/styles.css index 71b3564..b09636e 100644 --- a/src/miniapp/styles.css +++ b/src/miniapp/styles.css @@ -1536,8 +1536,7 @@ button { } /* Browser chrome also works at phone widths and with Safari's dynamic viewport. */ -.browserApp, -.signInRoot { +.browserApp { min-height: 100dvh; } @@ -1577,117 +1576,6 @@ button { color: var(--destructive); } -.signInRoot { - display: grid; - place-items: center; - padding: max(32px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) - max(32px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); - background: radial-gradient( - ellipse at 20% 0%, - color-mix(in srgb, var(--primary) 9%, transparent), - transparent 60% - ); -} - -.signInCard { - width: min(100%, 480px); - padding: 36px; - border: 1px solid var(--border); - border-radius: 24px; - background: var(--card); - box-shadow: 0 18px 64px #1020400a; -} - -.signInMark { - display: grid; - place-items: center; - width: 52px; - height: 52px; - margin-bottom: 24px; - border-radius: 16px; - background: var(--primary); - color: var(--primary-foreground); -} -.eyebrow { - margin: 0 0 12px; - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.15em; - color: var(--muted-foreground); -} -.signInCard h1 { - margin: 0; - font-size: clamp(1.8rem, 5vw, 2.15rem); - line-height: 1.18; - letter-spacing: -0.035em; -} -.signInIntro { - margin: 16px 0 28px; - color: var(--muted-foreground); - line-height: 1.65; -} -.signInInstructions { - padding: 18px; - margin-bottom: 26px; - border-radius: 14px; - background: var(--background); -} -.signInInstructions h2 { - margin: 0 0 10px; - font-size: 0.95rem; -} -.signInInstructions p { - font-size: 0.85rem; - line-height: 1.65; - color: var(--muted-foreground); - margin: 10px 0 0; -} -.signInInstructions code { - white-space: nowrap; - color: var(--foreground); - font-size: 0.8rem; -} -.signInCard form { - display: grid; - gap: 12px; -} -.signInCard label { - font-size: 0.85rem; - font-weight: 600; -} -.signInCard input { - width: 100%; - min-width: 0; - min-height: 48px; - border: 1px solid var(--input); - border-radius: 12px; - padding: 12px; - background: var(--background); - color: var(--foreground); -} -.signInCard input:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} -.signInFootnote { - margin: 24px 0 0; - color: var(--muted-foreground); - font-size: 0.75rem; - line-height: 1.6; -} -.signInError { - color: var(--destructive); - font-size: 0.85rem; - margin: 0; - overflow-wrap: anywhere; -} - -@media (max-width: 480px) { - .signInCard { - padding: 24px; - } -} - @media (min-width: 960px) { .browserApp.authenticatedApp { padding-left: 240px; diff --git a/test/miniapp-browser.test.ts b/test/miniapp-browser.test.ts index cfd7ff7..a26bfab 100644 --- a/test/miniapp-browser.test.ts +++ b/test/miniapp-browser.test.ts @@ -40,7 +40,23 @@ describe("Browser HTTP app", () => { expect((await request(path, { method: "HEAD" })).status).toBe(200); } expect((await request("/app/unknown")).status).toBe(404); - expect((await request("/api/config")).status).toBe(401); + expect((await request("/app", { method: "POST" })).status).toBe(405); + for (const path of [ + "/api/auth/session", + "/api/config", + "/api/config/validate", + "/api/skills", + "/api/skills/resource?skill=test", + "/api/usage", + "/api/usage/reset", + "/api/schedules", + "/api/schedules/test", + "/api/runtime/reload", + "/api/runtime/restart", + "/api/unknown", + ]) { + expect((await request(path, { method: "POST", headers: browserHeaders })).status).toBe(401); + } expect((await request("/healthz")).status).toBe(200); }); From 8a3301a79eb510b9c93fd17adf92a6ecf655a0b2 Mon Sep 17 00:00:00 2001 From: sadfun Date: Sat, 5 Sep 2026 23:22:58 +0200 Subject: [PATCH 3/3] Move document startup out of React and keep settings state in the form --- src/miniapp/app.tsx | 335 ++++++++++++---------------------- src/miniapp/client.tsx | 74 +++++++- src/miniapp/settings-form.tsx | 10 +- src/miniapp/styles.css | 40 ++-- src/miniapp/ui.tsx | 16 -- 5 files changed, 209 insertions(+), 266 deletions(-) diff --git a/src/miniapp/app.tsx b/src/miniapp/app.tsx index 40ad421..37beea5 100644 --- a/src/miniapp/app.tsx +++ b/src/miniapp/app.tsx @@ -1,23 +1,15 @@ /** Shared browser and Telegram application shell. */ import { CalendarClock, LogOut, SlidersHorizontal, Sparkles, Terminal } from "lucide-react"; -import { type ReactElement, useEffect, useRef, useState } from "react"; -import { - ConfigApiError, - exchangeLogin, - type LoadedSnapshot, - logoutBrowser, - requestSession, - requestSnapshot, -} from "./api.js"; +import { type ReactElement, useEffect, useState } from "react"; +import { requestSnapshot } from "./api.js"; import { SchedulesManager } from "./schedules.js"; import { SettingsForm } from "./settings-form.js"; import { messageOf, useAsync } from "./shared.js"; import { SkillsBrowser } from "./skills.js"; -import { navigateWithUnsavedGuard, telegramReady, webApp } from "./telegram.js"; -import { AppRoot, Button, Placeholder, Spinner, Tabbar } from "./ui.js"; +import { navigateWithUnsavedGuard, telegramReady } from "./telegram.js"; +import { Button, Placeholder, Spinner, Tabbar } from "./ui.js"; type AppTab = "schedules" | "settings" | "skills"; -type Session = { readonly provider: string } | null; const tabs = [ { id: "settings", label: "Settings", icon: SlidersHorizontal }, { id: "skills", label: "Skills", icon: Sparkles }, @@ -35,99 +27,18 @@ function tabUrl(tab: AppTab): string { return telegramReady ? `/miniapp?tab=${tab}${window.location.hash}` : `/app/${tab}`; } -export function SettingsApp({ loginToken }: { readonly loginToken: string | null }): ReactElement { - const [appearance, setAppearance] = useState<"dark" | "light">( - webApp?.colorScheme ?? - (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"), - ); - const [session, setSession] = useState( - telegramReady ? { provider: "telegram" } : undefined, - ); - const [authError, setAuthError] = useState(); - const [authBusy, setAuthBusy] = useState(false); +export function SettingsApp({ + provider, + onSignOut, +}: { + readonly provider: string; + readonly onSignOut: () => Promise; +}): ReactElement { const [activeTab, setActiveTab] = useState(tabFromLocation); - const [loadAttempt, setLoadAttempt] = useState(0); - const [snapshot, setSnapshot] = useState(); - const initialAuth = useRef | undefined>(undefined); - - useEffect(() => { - const app = webApp; - if (app !== undefined) { - const changed = (): void => setAppearance(app.colorScheme); - app.ready(); - app.expand(); - app.onEvent("themeChanged", changed); - return () => app.offEvent("themeChanged", changed); - } - const media = window.matchMedia("(prefers-color-scheme: dark)"); - const changed = (): void => setAppearance(media.matches ? "dark" : "light"); - media.addEventListener("change", changed); - return () => media.removeEventListener("change", changed); - }, []); - - useEffect(() => { - document.documentElement.dataset.appearance = appearance; - }, [appearance]); - - useEffect(() => { - const viewport = window.visualViewport; - if (telegramReady || viewport === null) return; - const resize = (): void => { - document.documentElement.style.setProperty( - "--browser-viewport-height", - `${viewport.height}px`, - ); - document.documentElement.style.setProperty( - "--browser-viewport-top", - `${viewport.offsetTop}px`, - ); - }; - resize(); - viewport.addEventListener("resize", resize); - viewport.addEventListener("scroll", resize); - return () => { - viewport.removeEventListener("resize", resize); - viewport.removeEventListener("scroll", resize); - }; - }, []); - - useEffect(() => { - if (telegramReady) return; - let active = true; - initialAuth.current ??= (async () => { - if (loginToken !== null) await exchangeLogin(loginToken); - try { - return await requestSession(); - } catch (error) { - if (loginToken === null && error instanceof ConfigApiError && error.status === 401) - return null; - throw error; - } - })(); - void initialAuth.current - .then((value) => { - if (active) setSession(value); - }) - .catch((error: unknown) => { - if (!active) return; - setAuthError(messageOf(error)); - setSession(null); - }); - return () => { - active = false; - }; - }, [loginToken]); - - useEffect(() => { - const expired = (): void => { - setSession(null); - setSnapshot(undefined); - setAuthError("Your session ended. Request a fresh sign-in link from the bot."); - }; - window.addEventListener("wirebot:session-expired", expired); - return () => window.removeEventListener("wirebot:session-expired", expired); - }, []); + const [signOutBusy, setSignOutBusy] = useState(false); + const [signOutError, setSignOutError] = useState(); + // Keep browser back/forward navigation behind the same draft guard as tab clicks. useEffect(() => { const onPopState = (): void => { const next = tabFromLocation(); @@ -143,25 +54,12 @@ export function SettingsApp({ loginToken }: { readonly loginToken: string | null return () => window.removeEventListener("popstate", onPopState); }, [activeTab]); - const snapshotLoad = useAsync(session ? () => requestSnapshot("GET") : undefined, [ - session, - loadAttempt, - ]); - useEffect(() => { - if (snapshotLoad.value !== undefined) setSnapshot(snapshotLoad.value); - }, [snapshotLoad.value]); - const signOut = (): void => navigateWithUnsavedGuard(() => { - setAuthBusy(true); - void logoutBrowser() - .then(() => { - setSession(null); - setSnapshot(undefined); - setAuthError(undefined); - }) - .catch((error: unknown) => setAuthError(messageOf(error))) - .finally(() => setAuthBusy(false)); + setSignOutBusy(true); + void onSignOut() + .catch((error: unknown) => setSignOutError(messageOf(error))) + .finally(() => setSignOutBusy(false)); }); const selectTab = (next: AppTab): void => { @@ -174,110 +72,105 @@ export function SettingsApp({ loginToken }: { readonly loginToken: string | null }; return ( - - {session === undefined ? ( -
- - - -
- ) : session === null ? ( -
- Sign in to Wirebot} - description={ -
-

Manage Codex settings, skills, and schedules through your bot.

-

- Send /wirebot web in a direct message to your Slack or Discord bot, - or /web in Telegram. Open the private link it replies with. -

-

Admin access only. Links work once and expire after 5 minutes.

- {authError && ( -

- {authError} -

- )} -
- } + <> + {!telegramReady && ( +
+ + Connected through {provider} + + +
+ )} + {signOutError && ( +

+ {signOutError} +

+ )} +
+ {activeTab === "settings" ? ( + + ) : activeTab === "skills" ? ( + + ) : ( + + )} +
+ +
-
- ) : ( - <> - {!telegramReady && ( -
- - Connected through {session.provider} - - -
- )} - {authError && ( -

- {authError} +

+ Sign in to Wirebot} + description={ +
+

Manage Codex settings, skills, and schedules through your bot.

+

+ Send /wirebot web in a direct message to your Slack or Discord bot, or{" "} + /web in Telegram. Open the private link it replies with.

- )} -
- {activeTab === "settings" ? ( - snapshot === undefined ? ( - setLoadAttempt((attempt) => attempt + 1)} - /> - ) : ( - - ) - ) : activeTab === "skills" ? ( - - ) : ( - +

Admin access only. Links work once and expire after 5 minutes.

+ {error && ( +

+ {error} +

)} -
- - - - )} - +
+ } + > +
+
); } diff --git a/src/miniapp/client.tsx b/src/miniapp/client.tsx index b64d3df..0e59733 100644 --- a/src/miniapp/client.tsx +++ b/src/miniapp/client.tsx @@ -1,5 +1,6 @@ -/** Load Telegram's SDK only for a Mini App launch; ordinary browsers stay standalone. */ +/** Document startup: load the host SDK, establish a session, then mount the app. */ import { createRoot } from "react-dom/client"; +import { Placeholder, Spinner } from "./ui.js"; const launch = new URLSearchParams(window.location.hash.slice(1)); const loginToken = launch.get("login"); @@ -15,12 +16,75 @@ if (loginToken === null && launch.has("tgWebAppData")) { document.head.append(script); }); } -const { SettingsApp } = await import("./app.js"); -const root = document.getElementById("root"); -if (root === null) throw new Error("Wirebot root element is missing"); -createRoot(root).render(); +// These modules read Telegram's SDK at import time. +const { SettingsApp, SignIn } = await import("./app.js"); +const { ConfigApiError, exchangeLogin, logoutBrowser, requestSession } = await import("./api.js"); +const { webApp } = await import("./telegram.js"); +const element = document.getElementById("root"); +if (element === null) throw new Error("Wirebot root element is missing"); +const root = createRoot(element); +element.className = `appRoot bg-background text-foreground ${webApp ? "telegramApp" : "browserApp"}`; +// Host listeners live for the document's lifetime, independently of React renders. +if (webApp !== undefined) { + const app = webApp; + const applyTheme = (): void => { + element.dataset.appearance = app.colorScheme; + element.style.colorScheme = app.colorScheme; + }; + applyTheme(); + app.onEvent("themeChanged", applyTheme); + app.ready(); + app.expand(); +} else if (window.visualViewport !== null) { + const viewport = window.visualViewport; + const resize = (): void => { + element.style.setProperty("--browser-viewport-height", `${viewport.height}px`); + element.style.setProperty("--browser-viewport-top", `${viewport.offsetTop}px`); + }; + resize(); + viewport.addEventListener("resize", resize); + viewport.addEventListener("scroll", resize); +} + +const showApp = (provider: string): void => { + element.classList.add("authenticatedApp"); + root.render(); +}; +const showSignIn = (error?: string): void => { + element.classList.remove("authenticatedApp"); + root.render(); +}; + +async function signOut(): Promise { + await logoutBrowser(); + showSignIn(); +} + +window.addEventListener("wirebot:session-expired", () => { + showSignIn("Your session ended. Request a fresh sign-in link from the bot."); +}); // A sign-in link opened in an already-open tab can be a same-document navigation. window.addEventListener("hashchange", () => { if (new URLSearchParams(window.location.hash.slice(1)).has("login")) window.location.reload(); }); + +if (webApp !== undefined) { + showApp("telegram"); +} else { + root.render( +
+ + + +
, + ); + try { + if (loginToken !== null) await exchangeLogin(loginToken); + showApp((await requestSession()).provider); + } catch (error) { + const signedOut = + loginToken === null && error instanceof ConfigApiError && error.status === 401; + showSignIn(signedOut ? undefined : error instanceof Error ? error.message : "Sign-in failed."); + } +} diff --git a/src/miniapp/settings-form.tsx b/src/miniapp/settings-form.tsx index fc8d03d..af70bb4 100644 --- a/src/miniapp/settings-form.tsx +++ b/src/miniapp/settings-form.tsx @@ -101,11 +101,11 @@ const defaultGranularApproval: GranularApproval = { }; interface SettingsFormProps { - readonly snapshot: LoadedSnapshot; - readonly onSnapshot: (snapshot: LoadedSnapshot) => void; + readonly initialSnapshot: LoadedSnapshot; } -export function SettingsForm({ snapshot, onSnapshot }: SettingsFormProps): ReactElement { +export function SettingsForm({ initialSnapshot }: SettingsFormProps): ReactElement { + const [snapshot, setSnapshot] = useState(initialSnapshot); const [draft, setDraft] = useState(snapshot.values); const [environmentText, setEnvironmentText] = useState(() => linesFromConfig(snapshot.values.shell_environment_include_only), @@ -219,7 +219,7 @@ export function SettingsForm({ snapshot, onSnapshot }: SettingsFormProps): React ...(remoteClientContextDirty ? { wirebot: { remoteClientContext } } : {}), }; const loaded = await requestSnapshot("PUT", body); - onSnapshot(loaded); + setSnapshot(loaded); setDraft(loaded.values); setEnvironmentText(linesFromConfig(loaded.values.shell_environment_include_only)); setGranular(granularApprovalOf(loaded.values.approval_policy)); @@ -256,7 +256,7 @@ export function SettingsForm({ snapshot, onSnapshot }: SettingsFormProps): React setNotice(action === "reload" ? "Applying Codex changes…" : "Restarting Codex…"); try { const runtime = await requestRuntime(action); - onSnapshot({ ...snapshot, runtime, writeOutcome: undefined }); + setSnapshot({ ...snapshot, runtime, writeOutcome: undefined }); setNotice(runtimeActionNotice(runtime, action)); notifyHaptic(runtime.state === "degraded" || runtime.restartRequired ? "warning" : "success"); } catch (error) { diff --git a/src/miniapp/styles.css b/src/miniapp/styles.css index b09636e..84937c7 100644 --- a/src/miniapp/styles.css +++ b/src/miniapp/styles.css @@ -51,24 +51,26 @@ text-rendering: optimizeLegibility; } -:root[data-appearance="dark"] { - color-scheme: dark; - --background: #10141c; - --foreground: #e5ebf5; - --card: #191f2b; - --primary: #90b4ff; - --primary-foreground: #122544; - --secondary: #263248; - --secondary-foreground: #d9e5ff; - --muted: #242d3c; - --muted-foreground: #a0aec3; - --accent: #293650; - --destructive: #ff8793; - --destructive-foreground: #35151a; - --border: #303c50; - --input: #42516a; - --section-header: #a0aec3; - --tabbar-background: #191f2b; +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --background: #10141c; + --foreground: #e5ebf5; + --card: #191f2b; + --primary: #90b4ff; + --primary-foreground: #122544; + --secondary: #263248; + --secondary-foreground: #d9e5ff; + --muted: #242d3c; + --muted-foreground: #a0aec3; + --accent: #293650; + --destructive: #ff8793; + --destructive-foreground: #35151a; + --border: #303c50; + --input: #42516a; + --section-header: #a0aec3; + --tabbar-background: #191f2b; + } } .telegramApp { @@ -107,7 +109,7 @@ text-rendering: optimizeLegibility; } -.telegramApp.dark { +.telegramApp[data-appearance="dark"] { --destructive-foreground: var(--tg-theme-text-color, var(--primary-foreground)); --switch-thumb: var(--tg-theme-text-color, var(--primary-foreground)); } diff --git a/src/miniapp/ui.tsx b/src/miniapp/ui.tsx index bda098f..e817bae 100644 --- a/src/miniapp/ui.tsx +++ b/src/miniapp/ui.tsx @@ -57,22 +57,6 @@ export const Button = forwardRef(function Button ); }); -interface AppRootProps extends HTMLAttributes { - readonly appearance: "dark" | "light"; -} - -export function AppRoot({ appearance, className, children, ...props }: AppRootProps): ReactElement { - return ( -
- {children} -
- ); -} - interface SectionProps extends HTMLAttributes { readonly header?: ReactNode; readonly footer?: ReactNode;