mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
feat(admin-ui): implement standalone admin UI with Cloudflare Pages deployment
This commit is contained in:
@@ -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 }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,6 @@ builder = "DOCKERFILE"
|
||||
dockerfilePath = "/apps/server/production/railway/Dockerfile"
|
||||
watchPatterns = [
|
||||
"apps/server/**",
|
||||
"apps/ui-admin/**",
|
||||
"packages/**",
|
||||
"pnpm-lock.yaml"
|
||||
]
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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<HonoEnv>()
|
||||
.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)))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_GT = />/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()
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/assets/*
|
||||
cache-control: max-age=31536000
|
||||
cache-control: immutable
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/ /admin/ 302
|
||||
/admin /index.html 200
|
||||
/admin/* /index.html 200
|
||||
@@ -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 = '<script id="airi-server-admin-context" type="application/json">__AIRI_SERVER_ADMIN_CONTEXT__</script>'
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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'), ''),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user