fix(server/docker): squash auth ui to server image
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ builder = "DOCKERFILE"
|
||||
dockerfilePath = "/apps/server/production/railway/Dockerfile"
|
||||
watchPatterns = [
|
||||
"apps/server/**",
|
||||
"apps/ui-server-auth/**",
|
||||
"packages/**",
|
||||
"pnpm-lock.yaml"
|
||||
]
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM scratch
|
||||
|
||||
WORKDIR /app/airi/projects/ui/ui-server-auth
|
||||
COPY . /app/airi/projects/ui/ui-server-auth
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user