From c0859c9798daad15e23a8d3e73f336c61b00f08b Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 28 Apr 2026 00:34:14 +0800 Subject: [PATCH] fix(server/docker): squash auth ui to server image --- .github/workflows/release-docker-assets.yml | 80 -------------- apps/server/Dockerfile | 26 ++++- .../ai-context/verifications/email-auth.md | 18 ++-- apps/server/production/railway/Dockerfile | 28 ++++- apps/server/railway.toml | 1 + apps/ui-server-auth/Dockerfile | 4 - packages/stage-ui/src/libs/auth.ts | 100 +++++++++--------- 7 files changed, 111 insertions(+), 146 deletions(-) delete mode 100644 .github/workflows/release-docker-assets.yml delete mode 100644 apps/ui-server-auth/Dockerfile diff --git a/.github/workflows/release-docker-assets.yml b/.github/workflows/release-docker-assets.yml deleted file mode 100644 index fc03a72c8..000000000 --- a/.github/workflows/release-docker-assets.yml +++ /dev/null @@ -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 }} diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 387be940b..0dc4dda9b 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -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 diff --git a/apps/server/docs/ai-context/verifications/email-auth.md b/apps/server/docs/ai-context/verifications/email-auth.md index 905ce1a0a..261701b2c 100644 --- a/apps/server/docs/ai-context/verifications/email-auth.md +++ b/apps/server/docs/ai-context/verifications/email-auth.md @@ -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: , 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/?callbackURL=http://localhost:5173/_ui/server-auth/reset-password` → 302 → UI form rendered with `?token=` | +| Click reset link | `GET /api/auth/reset-password/?callbackURL=http://localhost:5173/auth/reset-password` → 302 → UI form rendered with `?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: , 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=`. +3. Click the email link. Expect server validates and 302s to `${UI}/auth/reset-password?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. diff --git a/apps/server/production/railway/Dockerfile b/apps/server/production/railway/Dockerfile index 5cc76d694..0dc4dda9b 100644 --- a/apps/server/production/railway/Dockerfile +++ b/apps/server/production/railway/Dockerfile @@ -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 diff --git a/apps/server/railway.toml b/apps/server/railway.toml index 28975201d..0d16b927e 100644 --- a/apps/server/railway.toml +++ b/apps/server/railway.toml @@ -3,6 +3,7 @@ builder = "DOCKERFILE" dockerfilePath = "/apps/server/production/railway/Dockerfile" watchPatterns = [ "apps/server/**", + "apps/ui-server-auth/**", "packages/**", "pnpm-lock.yaml" ] diff --git a/apps/ui-server-auth/Dockerfile b/apps/ui-server-auth/Dockerfile deleted file mode 100644 index 3fe24b2bb..000000000 --- a/apps/ui-server-auth/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM scratch - -WORKDIR /app/airi/projects/ui/ui-server-auth -COPY . /app/airi/projects/ui/ui-server-auth diff --git a/packages/stage-ui/src/libs/auth.ts b/packages/stage-ui/src/libs/auth.ts index 774eb9909..e0b1cea1d 100644 --- a/packages/stage-ui/src/libs/auth.ts +++ b/packages/stage-ui/src/libs/auth.ts @@ -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(() => {}) - } } /**