fix(server/docker): squash auth ui to server image

This commit is contained in:
RainbowBird
2026-04-28 00:34:14 +08:00
parent 172e4ce59c
commit c0859c9798
7 changed files with 111 additions and 146 deletions
@@ -1,80 +0,0 @@
name: Release Docker / OCI for Assets
on:
push:
branches:
- 'main'
paths:
- 'apps/ui-server-auth/**'
workflow_dispatch:
jobs:
ghcr_build_assets:
name: Release Assets (${{ matrix.assets_name }})
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- assets_name: ui-server-auth
build_directory: ./apps/server/public/ui-server-auth
build_command: |
pnpm -F @proj-airi/ui-server-auth run build
steps:
# Why?
#
# failed to build archive at `/home/runner/work/airi/airi/target/x86_64-unknown-linux-gnu/release/deps/libapp_lib.rlib`:
# No space left on device (os error 28)
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
- uses: actions/checkout@v6
# Turborepo
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: ${{ matrix.build_command }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}/${{ matrix.assets_name }}
flavor: |
latest=true
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') }}
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./apps/server/public/ui-server-auth
file: ./apps/ui-server-auth/Dockerfile
build-args: |
VITE_ENABLE_POSTHOG=true
platforms: linux/amd64,linux/arm64,linux/arm64/v8
cache-from: type=gha
cache-to: type=gha,mode=max
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+24 -2
View File
@@ -1,16 +1,38 @@
# Stage 1: build ui-server-auth from source so the HTML's Vite `base` and
# the server's route prefix are guaranteed to come from the SAME commit.
# Pulling a prebuilt `ghcr.io/moeru-ai/airi/ui-server-auth:latest` image
# previously let the two drift (server route changed to `/auth/`, prebuilt
# HTML still at `/_ui/server-auth/` → every asset 404 in prod).
FROM node:24-alpine AS ui-build
WORKDIR /app
RUN corepack enable
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile
RUN pnpm -F @proj-airi/ui-server-auth run build
# Stage 2: server runtime. Vite emits the UI dist into
# /app/apps/server/public/ui-server-auth via its `outDir`, so we COPY from
# that exact path in the builder. Server install can stay --ignore-scripts
# since the UI is already built and the server has no native deps that
# need postinstall.
FROM node:24-alpine
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY patches/ ./patches/
COPY apps/server apps/server
COPY --from=ghcr.io/moeru-ai/airi/ui-server-auth:latest /app/airi/projects/ui/ui-server-auth apps/server/public/ui-server-auth
COPY packages/server-schema packages/server-schema
COPY packages/server-sdk-shared packages/server-sdk-shared
COPY --from=ui-build /app/apps/server/public/ui-server-auth apps/server/public/ui-server-auth
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile --ignore-scripts
@@ -1,9 +1,15 @@
# Verification: email auth via Resend
Status: **Path 1 verified**, Path 2/3 unverified.
Last attempted: 2026-04-27
Last attempted: 2026-04-28
Owner: rbxin2003@gmail.com
> **Note (2026-04-28):** UI base path migrated from the original
> `/_ui/server-auth/` prefix to `/auth/` (commit `d5f215134`). All Vite-built
> asset URLs and SPA routes are now served under `/auth/`. The verification
> log below has been rewritten to use the current routes; the prior path
> remains valid only for historical builds tagged before that commit.
## What's verified end-to-end
### Path 1 — Sign-up + verify email + sign-in (✅ 2026-04-27)
@@ -17,12 +23,12 @@ Tested with a live Resend API key, real Outlook inbox.
| Inbox delivery | User confirmed receipt at `rbxin2003@outlook.com` with subject "Verify your email", containing link `http://localhost:3000/api/auth/verify-email?token=eyJ...&callbackURL=%2F` |
| Click verify link | `GET /api/auth/verify-email?token=...&callbackURL=/``302` (redirect honored) |
| `emailVerified` flips to `true` | follow-up `POST /api/auth/sign-in/email` for the same user → `200` with `{ redirect: false, token: <session>, user: { ..., emailVerified: true, updatedAt > createdAt } }` |
| UI sign-up form submit | navigated `http://localhost:5174/_ui/server-auth/sign-up`, filled form via chrome-devtools, click `Create account` → server log `POST /api/auth/sign-up/email 200 2s` → browser landed on UI's verify-email page |
| UI sign-up form submit | navigated `http://localhost:5174/auth/sign-up`, filled form via chrome-devtools, click `Create account` → server log `POST /api/auth/sign-up/email 200 2s` → browser landed on UI's verify-email page |
Two follow-up issues surfaced and were fixed in the same session:
1. **vue-i18n linked-format crash** — placeholder `you@example.com` parsed as a linked-message reference. Escaped to `you{'@'}example.com` in `packages/i18n/src/locales/en/server/auth.yaml`.
2. **Email link landed on `http://localhost:3000/` (404)** when there was no OIDC context, because Better Auth resolves bare `/` callback against `API_SERVER_URL`. Fixed in `apps/ui-server-auth/src/pages/sign-up.vue` and `sign-in.vue` by passing an absolute UI URL (`${origin}/_ui/server-auth/verify-email?verified=true`) when no OIDC params are present.
2. **Email link landed on `http://localhost:3000/` (404)** when there was no OIDC context, because Better Auth resolves bare `/` callback against `API_SERVER_URL`. Fixed in `apps/ui-server-auth/src/pages/sign-up.vue` and `sign-in.vue` by passing an absolute UI URL (`${origin}/auth/verify-email?verified=true`) when no OIDC params are present.
3. **API root + 404 friendliness** — added structured JSON for `GET /` and `notFound()` in `apps/server/src/app.ts` so stale email links / scanners hit a clear pointer instead of hono's default `404 Not Found` HTML.
- Verified with `curl http://localhost:3000/``200 {"service":"airi-api",...}` and `curl http://localhost:3000/some/random/path``404 {"error":"NOT_FOUND",...}`.
@@ -33,10 +39,10 @@ Tested with `rbxin2003+reset@outlook.com` (live Resend account). The bare `rbxin
| Step | Evidence |
|---|---|
| Sign-up `rbxin2003+reset@outlook.com` | `POST /api/auth/sign-up/email 200 2s`; UI navigated to `/verify-email?email=...` |
| Verify email | clicked link from real Outlook inbox; `GET /api/auth/verify-email?token=...&callbackURL=http://localhost:5173/_ui/server-auth/verify-email?verified=true` → 302 → UI shows "Email verified" |
| Verify email | clicked link from real Outlook inbox; `GET /api/auth/verify-email?token=...&callbackURL=http://localhost:5173/auth/verify-email?verified=true` → 302 → UI shows "Email verified" |
| `POST /api/auth/request-password-reset` from UI | server log `200 3s`; UI shows "If rbxin2003+reset@outlook.com matches an account, a reset link is on the way" |
| Resend dashboard | `Reset your Project AIRI password` to `rbxin2003+reset@outlook.com``last_event: delivered` |
| Click reset link | `GET /api/auth/reset-password/<token>?callbackURL=http://localhost:5173/_ui/server-auth/reset-password` → 302 → UI form rendered with `?token=<token>` |
| Click reset link | `GET /api/auth/reset-password/<token>?callbackURL=http://localhost:5173/auth/reset-password` → 302 → UI form rendered with `?token=<token>` |
| Submit new password | `POST /api/auth/reset-password?token=...` → 200; UI shows "Password updated" |
| Sign in with new password | `POST /api/auth/sign-in/email``200` `{ token: <session>, user: { emailVerified: true, updatedAt: 2026-04-27T06:57:59.387Z } }` |
@@ -55,7 +61,7 @@ Two follow-up issues surfaced and were fixed in the same session:
1. From `/sign-in`, click "Forgot password?" → `/forgot-password`.
2. Submit the registered email. Expect `POST /api/auth/request-password-reset` returns 200, an email arrives ("Reset your Project AIRI password").
3. Click the email link. Expect server validates and 302s to `${UI}/_ui/server-auth/reset-password?token=<token>`.
3. Click the email link. Expect server validates and 302s to `${UI}/auth/reset-password?token=<token>`.
4. Submit a new password. Expect `POST /api/auth/reset-password?token=...` returns 200; UI shows "Password updated".
5. Sign in with the new password and confirm session is issued.
+26 -2
View File
@@ -1,3 +1,26 @@
# Stage 1: build ui-server-auth from source so the HTML's Vite `base` and
# the server's route prefix are guaranteed to come from the SAME commit.
# Pulling a prebuilt `ghcr.io/moeru-ai/airi/ui-server-auth:latest` image
# previously let the two drift (server route changed to `/auth/`, prebuilt
# HTML still at `/_ui/server-auth/` → every asset 404 in prod).
FROM node:24-alpine AS ui-build
WORKDIR /app
RUN corepack enable
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile
RUN pnpm -F @proj-airi/ui-server-auth run build
# Stage 2: server runtime. Vite emits the UI dist into
# /app/apps/server/public/ui-server-auth via its `outDir`, so we COPY from
# that exact path in the builder. Server install can stay --ignore-scripts
# since the UI is already built and the server has no native deps that
# need postinstall.
FROM node:24-alpine
WORKDIR /app
@@ -6,12 +29,13 @@ RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY patches/ ./patches/
COPY --from=ghcr.io/moeru-ai/airi/ui-server-auth:latest /app/airi/projects/ui/ui-server-auth apps/server/public/ui-server-auth
COPY apps/server apps/server
COPY packages/server-schema packages/server-schema
COPY packages/server-sdk-shared packages/server-sdk-shared
COPY --from=ui-build /app/apps/server/public/ui-server-auth apps/server/public/ui-server-auth
RUN pnpm install --frozen-lockfile --ignore-scripts
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile --ignore-scripts
RUN pnpm -F @proj-airi/server-schema run build
RUN pnpm -F @proj-airi/server-sdk-shared run build
+1
View File
@@ -3,6 +3,7 @@ builder = "DOCKERFILE"
dockerfilePath = "/apps/server/production/railway/Dockerfile"
watchPatterns = [
"apps/server/**",
"apps/ui-server-auth/**",
"packages/**",
"pnpm-lock.yaml"
]
-4
View File
@@ -1,4 +0,0 @@
FROM scratch
WORKDIR /app/airi/projects/ui/ui-server-auth
COPY . /app/airi/projects/ui/ui-server-auth
+48 -52
View File
@@ -114,63 +114,59 @@ export async function signOut() {
const clientId = authStore.oidcClientId
const bearerToken = authStore.token
// Optimistic logout — clear all client state synchronously so the UI
// (router guards, isAuthenticated watchers, logout hooks) can react in the
// same tick. The end-session round-trip below was previously awaited which
// gave a ~2s perceived stall on click; since the call is already
// best-effort (we swallow errors), it doesn't need to block the user.
// NOTICE:
// Authoritative server-side sign-out FIRST, then local clear. Do NOT make
// this optimistic.
//
// Why: the better-auth session cookie is SameSite=Lax. A top-level
// navigation to `/oauth2/authorize` (i.e. clicking "sign in" right after
// logout) will attach that cookie. If we clear local state first and let
// the user trigger a fresh OIDC flow before /end-session has actually
// deleted the session row, the server resolves the still-live row and
// silently re-issues tokens for the just-logged-out account. The user
// ends up logged back in as the previous identity.
//
// We pay the round-trip latency on the logout click in exchange for
// killing that race. Callers must display a loading indicator while
// awaiting (profile.vue gates the button via `signOutLoading`).
//
// OIDC RP-Initiated Logout (`/api/auth/oauth2/end-session`) is the
// Bearer-friendly path: it accepts `id_token_hint`, decodes the `sid`
// claim, and deletes the corresponding `session` row via
// `internalAdapter.deleteSession(session.token)`. Source:
// node_modules/@better-auth/oauth-provider/dist/index.mjs L996+. Requires
// the trusted OIDC client to be seeded with `enableEndSession: true`.
//
// Fallback to /api/auth/sign-out for sessions that pre-date id_token
// persistence (applyOIDCTokens started saving id_token in this branch);
// without it, those legacy sessions would skip server cleanup and hit
// exactly the silent-re-login bug described above.
try {
if (idTokenHint && clientId) {
const url = new URL('/api/auth/oauth2/end-session', SERVER_URL)
url.searchParams.set('id_token_hint', idTokenHint)
url.searchParams.set('client_id', clientId)
await fetch(url.toString(), { method: 'GET' })
}
else if (bearerToken) {
const url = new URL('/api/auth/sign-out', SERVER_URL)
await fetch(url.toString(), {
method: 'POST',
headers: { Authorization: `Bearer ${bearerToken}` },
})
}
}
catch {
// Network failure: still clear local state below. Server-side row will
// expire by TTL; the local refreshToken/idToken/clientId are about to
// be wiped, so the local user has no way to spend it in the meantime.
}
authStore.clearOIDCState()
authStore.user = null
authStore.session = null
authStore.token = null
authStore.refreshToken = null
// NOTICE:
// OIDC RP-Initiated Logout (`/api/auth/oauth2/end-session`) is the
// Bearer-friendly logout path. It accepts an `id_token_hint`, decodes the
// `sid` claim, and deletes the corresponding `session` row directly via
// `internalAdapter.deleteSession(session.token)` — no cookie required.
// Once the row is gone, even if the browser still carries a stale session
// cookie (cross-site SameSite=Lax can attach it on a top-level redirect to
// /oauth2/authorize), the server cannot resolve it to an active session,
// so the next sign-in attempt prompts proper authentication instead of
// silently re-issuing tokens.
//
// Requires the trusted OIDC client to be seeded with `enableEndSession: true`,
// which also gates whether the issued ID token carries the `sid` claim.
// Source: node_modules/@better-auth/oauth-provider/dist/index.mjs L996+
//
// Fire-and-forget: the user has already been logged out locally; this is
// best-effort server-side session cleanup. If it fails (offline, server
// down) the worst case is the server-side session row is orphaned until
// its TTL expires — better-auth will reject it on next use anyway.
if (idTokenHint && clientId) {
const url = new URL('/api/auth/oauth2/end-session', SERVER_URL)
url.searchParams.set('id_token_hint', idTokenHint)
url.searchParams.set('client_id', clientId)
fetch(url.toString(), { method: 'GET', keepalive: true }).catch(() => {})
return
}
// NOTICE:
// Fallback for sessions created before id_token persistence existed (legacy
// installs prior to applyOIDCTokens persisting `id_token`), or any code
// path that signed in without going through the OIDC client (e.g. a future
// direct credential flow). Without this branch those sessions would skip
// server-side cleanup entirely, leaving the row alive and allowing the
// next /oauth2/authorize hop to silently re-issue tokens (cookie attached
// via SameSite=Lax on top-level redirect).
//
// /api/auth/sign-out is the standard better-auth Bearer sign-out endpoint;
// it deletes the session row keyed off the Authorization header.
if (bearerToken) {
const url = new URL('/api/auth/sign-out', SERVER_URL)
fetch(url.toString(), {
method: 'POST',
headers: { Authorization: `Bearer ${bearerToken}` },
keepalive: true,
}).catch(() => {})
}
}
/**