From 11b820a77f8d771ee32ab4f1f60a83ace7ff08c2 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 9 Jun 2026 17:08:29 +0800 Subject: [PATCH] feat(admin-ui): implement standalone admin UI with Cloudflare Pages deployment --- .../workflows/deploy-cloudflare-admin-ui.yml | 55 ++++++++ .../workflows/deploy-cloudflare-auth-ui.yml | 4 +- .../deploy-cloudflare-workers-dev-server.yml | 51 ++++++- apps/server/Dockerfile | 17 --- apps/server/README.md | 10 ++ apps/server/production/railway/Dockerfile | 16 --- apps/server/railway.toml | 1 - apps/server/src/app.ts | 5 +- apps/server/src/libs/env.ts | 5 + apps/server/src/libs/tests/env.test.ts | 1 + apps/server/src/routes/admin-ui.ts | 26 +--- apps/server/src/utils/origin.ts | 2 + apps/server/src/utils/server-admin-ui.ts | 124 ++++++++++++------ apps/server/src/utils/tests/origin.test.ts | 4 +- .../src/utils/tests/server-admin-ui.test.ts | 45 +++++++ apps/ui-admin/README.md | 4 +- apps/ui-admin/public/_headers | 4 + apps/ui-admin/public/_redirects | 3 + .../src/modules/server-admin-context.test.ts | 39 ++++++ .../src/modules/server-admin-context.ts | 67 +++++++++- apps/ui-admin/vite.config.ts | 4 +- apps/ui-admin/vitest.config.ts | 14 ++ apps/ui-admin/wrangler.toml | 7 + vitest.config.ts | 1 + 24 files changed, 401 insertions(+), 108 deletions(-) create mode 100644 .github/workflows/deploy-cloudflare-admin-ui.yml create mode 100644 apps/server/src/utils/tests/server-admin-ui.test.ts create mode 100644 apps/ui-admin/public/_headers create mode 100644 apps/ui-admin/public/_redirects create mode 100644 apps/ui-admin/src/modules/server-admin-context.test.ts create mode 100644 apps/ui-admin/vitest.config.ts create mode 100644 apps/ui-admin/wrangler.toml diff --git a/.github/workflows/deploy-cloudflare-admin-ui.yml b/.github/workflows/deploy-cloudflare-admin-ui.yml new file mode 100644 index 000000000..6e39a1299 --- /dev/null +++ b/.github/workflows/deploy-cloudflare-admin-ui.yml @@ -0,0 +1,55 @@ +name: Cloudflare Pages (Admin UI) + +on: + push: + branches: + - 'main' + + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy - ui-admin + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + + environment: + name: Admin Production + url: https://admin.airi.build/admin/ + + steps: + - uses: actions/checkout@v6 + # Turborepo + - name: Cache turbo build setup + uses: actions/cache@v5 + with: + path: .turbo + key: ${{ runner.os }}-turbo-admin-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-admin- + ${{ runner.os }}-turbo- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: pnpm + - run: pnpm i -g wrangler@4 + - run: pnpm install --frozen-lockfile + - run: pnpm run build:packages + - name: Build ui-admin + run: pnpm -F @proj-airi/ui-admin run build + env: + VITE_SERVER_URL: 'https://api.airi.build' + + - uses: cloudflare/wrangler-action@v3.14.1 + with: + apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy ./apps/ui-admin/dist --project-name=airi-server-admin --branch=main + gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-cloudflare-auth-ui.yml b/.github/workflows/deploy-cloudflare-auth-ui.yml index 67a3ebc12..8a0648243 100644 --- a/.github/workflows/deploy-cloudflare-auth-ui.yml +++ b/.github/workflows/deploy-cloudflare-auth-ui.yml @@ -62,7 +62,7 @@ jobs: - uses: cloudflare/wrangler-action@v3.14.1 with: - apiToken: ${{ secrets.AUTH_CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.AUTH_CLOUDFLARE_ACCOUNT_ID }} + apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }} command: pages deploy ./apps/ui-server-auth/dist --project-name=moeru-ai-airi-auth --branch=main gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-cloudflare-workers-dev-server.yml b/.github/workflows/deploy-cloudflare-workers-dev-server.yml index 680630270..91f6d4137 100644 --- a/.github/workflows/deploy-cloudflare-workers-dev-server.yml +++ b/.github/workflows/deploy-cloudflare-workers-dev-server.yml @@ -122,11 +122,58 @@ jobs: - name: Wrangler Pages Deploy uses: cloudflare/wrangler-action@v3.14.1 with: - apiToken: ${{ secrets.AUTH_CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.AUTH_CLOUDFLARE_ACCOUNT_ID }} + apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }} command: pages deploy ./apps/ui-server-auth/dist --project-name=moeru-ai-airi-auth --branch=server-dev gitHubToken: ${{ secrets.GITHUB_TOKEN }} - name: Print preview URL run: | echo "Preview URL: https://server-dev.airi-server-auth.pages.dev/ui/" + + deploy-admin-ui: + name: Deploy - ui-admin (server-dev) + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + + environment: + name: AdminServerDev + url: https://server-dev.airi-server-admin.pages.dev/admin/ + + steps: + - uses: actions/checkout@v6 + # Turborepo + - name: Cache turbo build setup + uses: actions/cache@v5 + with: + path: .turbo + key: ${{ runner.os }}-turbo-admin-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-admin- + ${{ runner.os }}-turbo- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: pnpm + - run: pnpm i -g wrangler@4 + - run: pnpm install --frozen-lockfile + - run: pnpm run build:packages + - name: Build ui-admin + run: pnpm -F @proj-airi/ui-admin run build + env: + VITE_SERVER_URL: 'https://airi-server-dev.up.railway.app' + + - name: Wrangler Pages Deploy + uses: cloudflare/wrangler-action@v3.14.1 + with: + apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy ./apps/ui-admin/dist --project-name=airi-server-admin --branch=server-dev + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Print preview URL + run: | + echo "Preview URL: https://server-dev.airi-server-admin.pages.dev/admin/" diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 1225e8892..9e4d95ad5 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -1,19 +1,3 @@ -FROM node:24-alpine AS ui-assets - -WORKDIR /app - -RUN corepack enable - -RUN apk add --no-cache git - -COPY . . - -RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \ - pnpm install --frozen-lockfile --ignore-scripts - -RUN pnpm -r -F @proj-airi/ui-admin^... --if-present run build -RUN pnpm -F @proj-airi/ui-admin run build - FROM node:24-alpine WORKDIR /app @@ -23,7 +7,6 @@ RUN corepack enable COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ COPY patches/ ./patches/ COPY apps/server apps/server -COPY --from=ui-assets /app/apps/server/public/ui-admin apps/server/public/ui-admin COPY packages/server-schema packages/server-schema COPY packages/server-sdk-shared packages/server-sdk-shared diff --git a/apps/server/README.md b/apps/server/README.md index aca0cc98f..203f8cb28 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -36,6 +36,16 @@ Default: Set this when previewing or deploying auth UI to a different Cloudflare URL. +## `ADMIN_UI_URL` + +`apps/ui-admin` is deployed separately from the server image. The API server still owns the historical `/admin/*` entrypoints and redirects them to **`ADMIN_UI_URL`**. + +Default: + +`ADMIN_UI_URL=https://admin.airi.build/admin` + +Set this when previewing or deploying admin UI to a different Cloudflare URL. + ## `ADDITIONAL_TRUSTED_ORIGINS` (LAN / Capacitor dev) When the mobile dev server uses a non-localhost origin (for example `https://10.x.x.x:5273` from `cap copy ios` / `capacitor.config.json`), set **`ADDITIONAL_TRUSTED_ORIGINS`** in `apps/server/.env.local` to a comma-separated list of exact origins (parsed and normalized at startup). Example: diff --git a/apps/server/production/railway/Dockerfile b/apps/server/production/railway/Dockerfile index 78a003428..c92569a72 100644 --- a/apps/server/production/railway/Dockerfile +++ b/apps/server/production/railway/Dockerfile @@ -1,18 +1,3 @@ -FROM node:24-alpine AS ui-assets - -WORKDIR /app - -RUN corepack enable - -RUN apk add --no-cache git - -COPY . . - -RUN pnpm install --frozen-lockfile --ignore-scripts - -RUN pnpm -r -F @proj-airi/ui-admin^... --if-present run build -RUN pnpm -F @proj-airi/ui-admin run build - FROM node:24-alpine WORKDIR /app @@ -21,7 +6,6 @@ RUN corepack enable COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./ COPY patches/ ./patches/ -COPY --from=ui-assets /app/apps/server/public/ui-admin apps/server/public/ui-admin COPY apps/server apps/server COPY packages/server-schema packages/server-schema COPY packages/server-sdk-shared packages/server-sdk-shared diff --git a/apps/server/railway.toml b/apps/server/railway.toml index 1e0f24420..28975201d 100644 --- a/apps/server/railway.toml +++ b/apps/server/railway.toml @@ -3,7 +3,6 @@ builder = "DOCKERFILE" dockerfilePath = "/apps/server/production/railway/Dockerfile" watchPatterns = [ "apps/server/**", - "apps/ui-admin/**", "packages/**", "pnpm-lock.yaml" ] diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 47a644ce3..1f4d103d0 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -334,8 +334,9 @@ export async function buildApp(deps: AppDeps) { })) /** - * Admin dashboard SPA. Auth is enforced by `/api/admin/*`; the bundle - * itself is public so unauthenticated users can be redirected cleanly. + * Admin dashboard entrypoint. Auth is enforced by `/api/admin/*`; the + * standalone UI itself is public so unauthenticated users can be redirected + * cleanly. */ .route('/', createAdminUiRoutes(deps.env)) diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 4f15e8b4a..0656ba16b 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -85,6 +85,11 @@ const EnvSchema = object({ // of the server image. AUTH_UI_URL: optional(string(), 'https://auth.airi.build/ui'), + // Standalone admin UI base URL. The server keeps `/admin/*` as the historical + // entrypoint and redirects those requests here after ui-admin moved out of + // the server image. + ADMIN_UI_URL: optional(string(), 'https://admin.airi.build/admin'), + // Canonical user-facing web app origin. Used as the Stripe redirect base // (success_url / cancel_url / portal return_url) when a request has no trusted // browser origin — notably the Electron desktop renderer, which loads from diff --git a/apps/server/src/libs/tests/env.test.ts b/apps/server/src/libs/tests/env.test.ts index 25f86c59a..8b0a8c687 100644 --- a/apps/server/src/libs/tests/env.test.ts +++ b/apps/server/src/libs/tests/env.test.ts @@ -42,6 +42,7 @@ describe('parseEnv', () => { expect(env.DATABASE_URL).toBe('postgres://example') expect(env.REDIS_URL).toBe('redis://example') expect(env.AUTH_UI_URL).toBe('https://auth.airi.build/ui') + expect(env.ADMIN_UI_URL).toBe('https://admin.airi.build/admin') expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([]) }) diff --git a/apps/server/src/routes/admin-ui.ts b/apps/server/src/routes/admin-ui.ts index e9be72870..83682429e 100644 --- a/apps/server/src/routes/admin-ui.ts +++ b/apps/server/src/routes/admin-ui.ts @@ -1,32 +1,12 @@ import type { Env } from '../libs/env' import type { HonoEnv } from '../types/hono' -import { serveStatic } from '@hono/node-server/serve-static' import { Hono } from 'hono' -import { getServerAdminUiDistDir, renderServerAdminUiHtml, SERVER_ADMIN_UI_BASE_PATH } from '../utils/server-admin-ui' - -const RE_SERVER_ADMIN_UI_BASE_PATH = /^\/admin/ +import { buildAdminUiRedirectUrl, SERVER_ADMIN_UI_BASE_PATH } from '../utils/server-admin-ui' export function createAdminUiRoutes(env: Env) { return new Hono() - .get(SERVER_ADMIN_UI_BASE_PATH, c => c.redirect(`${SERVER_ADMIN_UI_BASE_PATH}/`)) - .get(`${SERVER_ADMIN_UI_BASE_PATH}/*`, async (c, next) => { - if (!shouldRenderAdminUiHtml(new URL(c.req.url).pathname)) - return next() - - return c.html(renderServerAdminUiHtml({ - apiServerUrl: env.API_SERVER_URL, - currentUrl: c.req.url, - })) - }) - .use(`${SERVER_ADMIN_UI_BASE_PATH}/*`, serveStatic({ - root: getServerAdminUiDistDir(), - rewriteRequestPath: (path: string) => path.replace(RE_SERVER_ADMIN_UI_BASE_PATH, ''), - })) -} - -function shouldRenderAdminUiHtml(pathname: string): boolean { - const segment = pathname.split('/').pop() ?? '' - return segment === '' || segment === 'index.html' || !segment.includes('.') + .get(SERVER_ADMIN_UI_BASE_PATH, c => c.redirect(buildAdminUiRedirectUrl(env.ADMIN_UI_URL, c.req.url, env.API_SERVER_URL))) + .get(`${SERVER_ADMIN_UI_BASE_PATH}/*`, c => c.redirect(buildAdminUiRedirectUrl(env.ADMIN_UI_URL, c.req.url, env.API_SERVER_URL))) } diff --git a/apps/server/src/utils/origin.ts b/apps/server/src/utils/origin.ts index 05b21f7d1..9540b8601 100644 --- a/apps/server/src/utils/origin.ts +++ b/apps/server/src/utils/origin.ts @@ -15,6 +15,8 @@ const TRUSTED_EXACT_ORIGINS = [ 'https://airi.moeru.ai', // Production 'https://auth.airi.build', // Standalone auth UI 'https://server-dev.airi-server-auth.pages.dev', // Server-dev standalone auth UI + 'https://admin.airi.build', // Standalone admin UI + 'https://server-dev.airi-server-admin.pages.dev', // Server-dev standalone admin UI ] // NOTICE: diff --git a/apps/server/src/utils/server-admin-ui.ts b/apps/server/src/utils/server-admin-ui.ts index 3b4f94c1a..5239c3cd4 100644 --- a/apps/server/src/utils/server-admin-ui.ts +++ b/apps/server/src/utils/server-admin-ui.ts @@ -1,49 +1,97 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' - export const SERVER_ADMIN_UI_BASE_PATH = '/admin' +export const ADMIN_UI_API_SERVER_URL_QUERY_PARAM = 'api_server_url' +export const DEFAULT_ADMIN_UI_URL = 'https://admin.airi.build/admin' +export const SERVER_DEV_API_SERVER_URL = 'https://airi-server-dev.up.railway.app' +export const SERVER_DEV_ADMIN_UI_URL = 'https://server-dev.airi-server-admin.pages.dev/admin' -const SERVER_ADMIN_UI_DIST_DIR = fileURLToPath(new URL('../../public/ui-admin', import.meta.url)) -const SERVER_ADMIN_UI_INDEX_HTML_PATH = fileURLToPath(new URL('../../public/ui-admin/index.html', import.meta.url)) -const RE_HTML_LT = //g -const RE_HTML_AMP = /&/g -const RE_UNICODE_LINE_SEPARATOR = /\u2028/g -const RE_UNICODE_PARAGRAPH_SEPARATOR = /\u2029/g +/** + * Builds an absolute URL inside the externally hosted admin UI. + * + * Use when: + * - Redirecting server-owned admin UI entrypoints to the standalone + * `apps/ui-admin` deployment. + * - Preserving dashboard route paths and query parameters. + * + * Expects: + * - `adminUiUrl` is the public admin UI base, usually ending in `/admin`. + * - `path` is the route path within the admin UI router. + * + * Returns: + * - An absolute URL with the admin UI base path, normalized path, and search. + */ +export function buildAdminUiUrl(adminUiUrl: string, path: string, search = ''): string { + const target = new URL(adminUiUrl) + const basePath = target.pathname.replace(/\/+$/, '') + const routePath = path.startsWith('/') ? path : `/${path}` -let cachedIndexHtml: string | null = null + target.pathname = `${basePath}${routePath}` + target.search = search + target.hash = '' -export interface ServerAdminUiContext { - apiServerUrl: string - currentUrl: string + return target.toString() } -export function getServerAdminUiDistDir(): string { - return SERVER_ADMIN_UI_DIST_DIR +/** + * Resolves the standalone admin UI base for the active server environment. + * + * Use when: + * - The server redirects historical `/admin/*` entrypoints to the standalone UI. + * - The server-dev Railway deployment needs the matching Cloudflare Pages + * branch without changing the production admin domain. + * + * Expects: + * - `adminUiUrl` is the configured admin UI base URL. + * - `apiServerUrl` is the configured API server URL. + * + * Returns: + * - The configured admin UI URL, except for the server-dev default pairing where + * the matching Pages branch URL is returned. + */ +export function resolveAdminUiUrl(adminUiUrl: string, apiServerUrl: string): string { + try { + const adminUi = new URL(adminUiUrl) + const defaultAdminUi = new URL(DEFAULT_ADMIN_UI_URL) + const apiServer = new URL(apiServerUrl) + const adminUiBase = `${adminUi.origin}${adminUi.pathname.replace(/\/+$/, '')}` + const defaultAdminUiBase = `${defaultAdminUi.origin}${defaultAdminUi.pathname.replace(/\/+$/, '')}` + + if (adminUiBase === defaultAdminUiBase && apiServer.origin === SERVER_DEV_API_SERVER_URL) { + return SERVER_DEV_ADMIN_UI_URL + } + } + catch { + return adminUiUrl + } + + return adminUiUrl } -export function renderServerAdminUiHtml(context: ServerAdminUiContext): string { - const indexHtml = getServerAdminUiIndexHtml() +/** + * Maps a server `/admin/*` request to the standalone admin UI. + * + * Use when: + * - The server keeps owning the historical `/admin/*` entrypoint but no longer + * packages the admin UI bundle. + * + * Expects: + * - `requestUrl` is the incoming server URL. + * - `adminUiUrl` points to the standalone admin UI base path. + * + * Returns: + * - The external admin UI URL preserving route suffix and query string. + */ +export function buildAdminUiRedirectUrl(adminUiUrl: string, requestUrl: string, apiServerUrl?: string): string { + const request = new URL(requestUrl) + const suffix = request.pathname === SERVER_ADMIN_UI_BASE_PATH + ? '/' + : request.pathname.slice(SERVER_ADMIN_UI_BASE_PATH.length) || '/' + const resolvedAdminUiUrl = apiServerUrl ? resolveAdminUiUrl(adminUiUrl, apiServerUrl) : adminUiUrl - if (!indexHtml.includes('__AIRI_SERVER_ADMIN_CONTEXT__')) - throw new Error('ui-admin index.html is missing __AIRI_SERVER_ADMIN_CONTEXT__ placeholder') + const target = new URL(buildAdminUiUrl(resolvedAdminUiUrl, suffix, request.search)) + if (apiServerUrl) { + const apiServer = new URL(apiServerUrl) + target.searchParams.set(ADMIN_UI_API_SERVER_URL_QUERY_PARAM, apiServer.origin) + } - return indexHtml.replace('__AIRI_SERVER_ADMIN_CONTEXT__', serializeInlineJson(context)) -} - -function getServerAdminUiIndexHtml(): string { - if (cachedIndexHtml !== null) - return cachedIndexHtml - - cachedIndexHtml = readFileSync(SERVER_ADMIN_UI_INDEX_HTML_PATH, 'utf8') - return cachedIndexHtml -} - -function serializeInlineJson(value: unknown): string { - return JSON.stringify(value) - .replace(RE_HTML_LT, '\\u003c') - .replace(RE_HTML_GT, '\\u003e') - .replace(RE_HTML_AMP, '\\u0026') - .replace(RE_UNICODE_LINE_SEPARATOR, '\\u2028') - .replace(RE_UNICODE_PARAGRAPH_SEPARATOR, '\\u2029') + return target.toString() } diff --git a/apps/server/src/utils/tests/origin.test.ts b/apps/server/src/utils/tests/origin.test.ts index f62bb7153..031c2ab74 100644 --- a/apps/server/src/utils/tests/origin.test.ts +++ b/apps/server/src/utils/tests/origin.test.ts @@ -12,9 +12,11 @@ describe('origin utils', () => { expect(getTrustedOrigin('https://127.0.0.1:5273')).toBe('https://127.0.0.1:5273') }) - it('allows the standalone auth UI origin', () => { + it('allows the standalone auth and admin UI origins', () => { expect(getTrustedOrigin('https://auth.airi.build')).toBe('https://auth.airi.build') expect(getTrustedOrigin('https://server-dev.airi-server-auth.pages.dev')).toBe('https://server-dev.airi-server-auth.pages.dev') + expect(getTrustedOrigin('https://admin.airi.build')).toBe('https://admin.airi.build') + expect(getTrustedOrigin('https://server-dev.airi-server-admin.pages.dev')).toBe('https://server-dev.airi-server-admin.pages.dev') }) it('rejects private LAN Vite dev origins unless listed in ADDITIONAL_TRUSTED_ORIGINS', () => { diff --git a/apps/server/src/utils/tests/server-admin-ui.test.ts b/apps/server/src/utils/tests/server-admin-ui.test.ts new file mode 100644 index 000000000..20a05a98f --- /dev/null +++ b/apps/server/src/utils/tests/server-admin-ui.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { buildAdminUiRedirectUrl, buildAdminUiUrl, resolveAdminUiUrl } from '../server-admin-ui' + +describe('admin UI URL helpers', () => { + it('builds admin UI URLs under the configured admin base path', () => { + expect(buildAdminUiUrl('https://admin.airi.build/admin', '/users', '?query=alice')).toBe( + 'https://admin.airi.build/admin/users?query=alice', + ) + }) + + it('maps server /admin requests to the standalone admin UI while preserving queries', () => { + expect(buildAdminUiRedirectUrl( + 'https://admin.airi.build/admin/', + 'https://api.airi.build/admin/voice-packs?provider=openai', + )).toBe('https://admin.airi.build/admin/voice-packs?provider=openai') + }) + + it('adds the API server origin for standalone admin UI cross-environment redirects', () => { + expect(buildAdminUiRedirectUrl( + 'https://admin-preview.example/admin/', + 'https://airi-server-dev.up.railway.app/admin/users?api_server_url=https%3A%2F%2Fevil.example', + 'https://airi-server-dev.up.railway.app/api/admin', + )).toBe( + 'https://admin-preview.example/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app', + ) + }) + + it('routes server-dev default admin UI redirects to the matching Pages branch', () => { + expect(buildAdminUiRedirectUrl( + 'https://admin.airi.build/admin', + 'https://airi-server-dev.up.railway.app/admin/users?query=alice', + 'https://airi-server-dev.up.railway.app', + )).toBe( + 'https://server-dev.airi-server-admin.pages.dev/admin/users?query=alice&api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app', + ) + }) + + it('keeps an explicitly configured admin UI URL for server-dev', () => { + expect(resolveAdminUiUrl( + 'https://admin-preview.example/admin', + 'https://airi-server-dev.up.railway.app', + )).toBe('https://admin-preview.example/admin') + }) +}) diff --git a/apps/ui-admin/README.md b/apps/ui-admin/README.md index 26362fdeb..5cacdf5b6 100644 --- a/apps/ui-admin/README.md +++ b/apps/ui-admin/README.md @@ -1,6 +1,6 @@ # AIRI Admin Dashboard -Admin dashboard for operating the hosted AIRI server. It is a Vue/Vite app built into `apps/server/public/ui-admin` and served by the server at `/admin`. +Admin dashboard for operating the hosted AIRI server. It is a standalone Vue/Vite app deployed to Cloudflare Pages under `/admin`; the API server redirects its historical `/admin/*` entrypoints to this app. ## Use When @@ -22,4 +22,4 @@ pnpm -F @proj-airi/ui-admin build ## Build Output -`pnpm -F @proj-airi/ui-admin build` writes to `apps/server/public/ui-admin`. Build this app before running a server image or local server flow that needs `/admin` to serve real HTML instead of reporting a missing admin UI artifact. +`pnpm -F @proj-airi/ui-admin build` writes to `apps/ui-admin/dist`. Server builds do not package this output; deploy the directory through the admin Cloudflare Pages workflow. diff --git a/apps/ui-admin/public/_headers b/apps/ui-admin/public/_headers new file mode 100644 index 000000000..7f40d0894 --- /dev/null +++ b/apps/ui-admin/public/_headers @@ -0,0 +1,4 @@ +/assets/* + cache-control: max-age=31536000 + cache-control: immutable + diff --git a/apps/ui-admin/public/_redirects b/apps/ui-admin/public/_redirects new file mode 100644 index 000000000..4af6262b7 --- /dev/null +++ b/apps/ui-admin/public/_redirects @@ -0,0 +1,3 @@ +/ /admin/ 302 +/admin /index.html 200 +/admin/* /index.html 200 diff --git a/apps/ui-admin/src/modules/server-admin-context.test.ts b/apps/ui-admin/src/modules/server-admin-context.test.ts new file mode 100644 index 000000000..6930065f4 --- /dev/null +++ b/apps/ui-admin/src/modules/server-admin-context.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' + +import { getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } from './server-admin-context' + +describe('ui-admin bootstrap context', () => { + it('uses the trusted API server origin carried by standalone server redirects', () => { + expect(resolveStandaloneServerAdminContext( + 'https://admin.airi.build/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fadmin', + )).toEqual({ + apiServerUrl: 'https://airi-server-dev.up.railway.app', + currentUrl: 'https://admin.airi.build/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fadmin', + }) + }) + + it('ignores untrusted API server origins from crafted standalone admin URLs', () => { + expect(resolveStandaloneServerAdminContext( + 'https://admin.airi.build/admin/users?api_server_url=https%3A%2F%2Fevil.example', + )).toBeNull() + }) + + it('allows localhost API origins for local development', () => { + expect(resolveStandaloneServerAdminContext( + 'http://localhost:5173/admin/users?api_server_url=http%3A%2F%2F127.0.0.1%3A3000', + )?.apiServerUrl).toBe('http://127.0.0.1:3000') + }) + + it('falls back to the standalone query context when the static placeholder script is still present', () => { + document.body.innerHTML = '' + window.history.replaceState( + null, + '', + '/admin/users?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app', + ) + + expect(getServerAdminBootstrapContext()?.apiServerUrl).toBe('https://airi-server-dev.up.railway.app') + }) +}) diff --git a/apps/ui-admin/src/modules/server-admin-context.ts b/apps/ui-admin/src/modules/server-admin-context.ts index 708fa287c..74d52756b 100644 --- a/apps/ui-admin/src/modules/server-admin-context.ts +++ b/apps/ui-admin/src/modules/server-admin-context.ts @@ -4,6 +4,19 @@ export interface ServerAdminBootstrapContext { } const SCRIPT_ID = 'airi-server-admin-context' +const API_SERVER_URL_QUERY_PARAM = 'api_server_url' + +const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [ + 'https://api.airi.build', + 'https://airi-server-dev.up.railway.app', +] + +const TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS = [ + /^http:\/\/localhost(:\d+)?$/, + /^http:\/\/127\.0\.0\.1(:\d+)?$/, + /^https:\/\/localhost(:\d+)?$/, + /^https:\/\/127\.0\.0\.1(:\d+)?$/, +] let cachedContext: ServerAdminBootstrapContext | null | undefined @@ -13,7 +26,7 @@ export function getServerAdminBootstrapContext(): ServerAdminBootstrapContext | const element = document.getElementById(SCRIPT_ID) if (!element) { - cachedContext = null + cachedContext = resolveStandaloneServerAdminContext(window.location.href) return cachedContext } @@ -26,7 +39,7 @@ export function getServerAdminBootstrapContext(): ServerAdminBootstrapContext | return cachedContext } catch { - cachedContext = null + cachedContext = resolveStandaloneServerAdminContext(window.location.href) return cachedContext } } @@ -34,3 +47,53 @@ export function getServerAdminBootstrapContext(): ServerAdminBootstrapContext | export function defaultApiServerUrl(): string { return import.meta.env.VITE_SERVER_URL || window.location.origin } + +/** + * Resolves API-server context carried by server redirects into static admin UI. + * + * Use when: + * - The standalone admin UI serves more than one AIRI environment from the same + * Pages deployment. + * + * Expects: + * - The server-owned `/admin/*` redirect sets `api_server_url`. + * - Only known AIRI API origins and localhost development origins are accepted. + * + * Returns: + * - A bootstrap context using the trusted API origin, or null when no trusted + * override is present. + */ +export function resolveStandaloneServerAdminContext(currentUrl: string): ServerAdminBootstrapContext | null { + const url = new URL(currentUrl) + const apiServerUrl = normalizeTrustedApiServerUrl( + url.searchParams.get(API_SERVER_URL_QUERY_PARAM), + ) + + if (!apiServerUrl) + return null + + return { + apiServerUrl, + currentUrl, + } +} + +function normalizeTrustedApiServerUrl(value: string | null): string | null { + if (!value) + return null + + try { + const origin = new URL(value).origin + + if (TRUSTED_STANDALONE_API_SERVER_ORIGINS.includes(origin)) + return origin + + if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin))) + return origin + + return null + } + catch { + return null + } +} diff --git a/apps/ui-admin/vite.config.ts b/apps/ui-admin/vite.config.ts index ce54e67e9..0a141e704 100644 --- a/apps/ui-admin/vite.config.ts +++ b/apps/ui-admin/vite.config.ts @@ -7,7 +7,7 @@ import VueMacros from 'vue-macros/vite' import { defineConfig } from 'vite' export default defineConfig({ - base: '/admin/', + base: '/', resolve: { alias: { '@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')), @@ -20,7 +20,7 @@ export default defineConfig({ }, build: { emptyOutDir: true, - outDir: resolve(join(import.meta.dirname, '..', 'server', 'public', 'ui-admin')), + outDir: resolve(join(import.meta.dirname, 'dist')), sourcemap: true, }, plugins: [ diff --git a/apps/ui-admin/vitest.config.ts b/apps/ui-admin/vitest.config.ts new file mode 100644 index 000000000..aafb40c95 --- /dev/null +++ b/apps/ui-admin/vitest.config.ts @@ -0,0 +1,14 @@ +import { join } from 'node:path' +import { cwd } from 'node:process' + +import { loadEnv } from 'vite' +import { defineConfig } from 'vitest/config' + +export default defineConfig(({ mode }) => { + return { + test: { + include: ['src/**/*.test.ts'], + env: loadEnv(mode, join(cwd(), 'apps', 'ui-admin'), ''), + }, + } +}) diff --git a/apps/ui-admin/wrangler.toml b/apps/ui-admin/wrangler.toml new file mode 100644 index 000000000..a55ebf004 --- /dev/null +++ b/apps/ui-admin/wrangler.toml @@ -0,0 +1,7 @@ +name = "airi-server-admin" +compatibility_date = "2025-04-01" +preview_urls = true + +[assets] +directory = "./dist" +not_found_handling = "single-page-application" diff --git a/vitest.config.ts b/vitest.config.ts index 23d011e44..46fd9ca54 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ projects: [ 'apps/server', 'apps/ui-server-auth', + 'apps/ui-admin', 'apps/stage-tamagotchi', 'packages/audio-pipelines-transcribe', 'packages/cap-vite',