From d5a861c723706f61a17fc74ee43d1d4b3801bf30 Mon Sep 17 00:00:00 2001
From: Ran <16112591+chen-ran@users.noreply.github.com>
Date: Tue, 24 Feb 2026 04:56:27 +0800
Subject: [PATCH 1/8] chore(ci): optimize docker builds with native arm64
runners
---
.github/workflows/docker.yml | 168 +++++++++++++++++++++++++----------
docker/Dockerfile.agent | 3 +-
docker/Dockerfile.mcp | 9 +-
docker/Dockerfile.server | 51 ++++-------
docker/Dockerfile.web | 2 +-
5 files changed, 150 insertions(+), 83 deletions(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 7baef3af..53f765b9 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -22,16 +22,22 @@ concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
+env:
+ PUSH: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') }}
+ REGISTRY: ghcr.io
+
permissions:
contents: read
packages: write
id-token: write
jobs:
- docker:
- runs-on: ubuntu-latest
+ build:
strategy:
+ fail-fast: false
matrix:
+ image: [server, agent, web, mcp]
+ platform: [linux/amd64, linux/arm64]
include:
- image: server
dockerfile: docker/Dockerfile.server
@@ -41,10 +47,114 @@ jobs:
dockerfile: docker/Dockerfile.web
- image: mcp
dockerfile: docker/Dockerfile.mcp
+ - platform: linux/amd64
+ runner: ubuntu-latest
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v4
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Set up Go
+ if: matrix.image == 'server' || matrix.image == 'mcp'
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.25'
+ cache: true
+
+ - name: Pre-warm Go mod cache
+ if: matrix.image == 'server' || matrix.image == 'mcp'
+ run: |
+ mkdir -p .go-cache
+ GOMODCACHE=$(pwd)/.go-cache go mod download
+
+ - name: Login to Docker Hub
+ if: env.PUSH == 'true'
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GitHub Container Registry
+ if: env.PUSH == 'true'
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push by digest
+ id: build
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ${{ matrix.dockerfile }}
+ platforms: ${{ matrix.platform }}
+ outputs: ${{ env.PUSH == 'true' && format('type=image,"name={0}/{1}/{2}",push-by-digest=true,name-canonical=true,push=true,compression=zstd', env.REGISTRY, github.repository_owner, matrix.image) || '' }}
+ build-contexts: ${{ (matrix.image == 'server' || matrix.image == 'mcp') && format('gomodcache={0}/.go-cache', github.workspace) || '' }}
+ build-args: |
+ VERSION=${{ github.ref_name }}
+ COMMIT_HASH=${{ github.sha }}
+ VITE_API_URL=/api
+ VITE_AGENT_URL=/agent
+ cache-from: |
+ type=gha,scope=${{ matrix.image }}-${{ matrix.platform }}
+ type=registry,ref=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:buildcache-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
+ cache-to: |
+ type=gha,scope=${{ matrix.image }}-${{ matrix.platform }},mode=max
+ ${{ env.PUSH == 'true' && format('type=registry,ref={0}/{1}/{2}:buildcache-{3},mode=max,compression=zstd', env.REGISTRY, github.repository_owner, matrix.image, matrix.platform == 'linux/amd64' && 'amd64' || 'arm64') || '' }}
+
+ - name: Export digest
+ if: env.PUSH == 'true'
+ run: |
+ mkdir -p /tmp/digests
+ digest="${{ steps.build.outputs.digest }}"
+ touch "/tmp/digests/${digest#sha256:}"
+
+ - name: Upload digest
+ if: env.PUSH == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: digests-${{ matrix.image }}-${{ strategy.job-index }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ merge:
+ runs-on: ubuntu-latest
+ if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release')
+ needs: build
+ strategy:
+ matrix:
+ image: [server, agent, web, mcp]
+ steps:
+ - name: Download digests
+ uses: actions/download-artifact@v4
+ with:
+ path: /tmp/digests
+ pattern: digests-${{ matrix.image }}-*
+ merge-multiple: true
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -59,50 +169,12 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- type=sha
- labels: |
- org.opencontainers.image.title=memoh-${{ matrix.image }}
- org.opencontainers.image.description=Memoh ${{ matrix.image }} - Multi-member AI agent platform
- org.opencontainers.image.vendor=memohai
- - name: Set up QEMU
- if: startsWith(github.ref, 'refs/tags/')
- uses: docker/setup-qemu-action@v3
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Login to Docker Hub
- if: github.event_name != 'pull_request'
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Login to GitHub Container Registry
- if: github.event_name != 'pull_request'
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Build and push
- uses: docker/build-push-action@v6
- with:
- context: .
- file: ${{ matrix.dockerfile }}
- push: ${{ github.event_name != 'pull_request' }}
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- platforms: ${{ startsWith(github.ref, 'refs/tags/') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
- build-args: |
- VERSION=${{ steps.meta.outputs.version }}
- COMMIT_HASH=${{ github.sha }}
- BUILD_TIME=${{ fromJson(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
- VITE_API_URL=/api
- VITE_AGENT_URL=/agent
- provenance: ${{ startsWith(github.ref, 'refs/tags/') }}
- sbom: ${{ startsWith(github.ref, 'refs/tags/') }}
- cache-from: type=gha,scope=${{ matrix.image }}
- cache-to: type=gha,scope=${{ matrix.image }},mode=max
+ - name: Create manifest list and push
+ working-directory: /tmp/digests
+ run: |
+ docker buildx imagetools create \
+ $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf 'ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}@sha256:%s ' *)
+ env:
+ DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent
index 891a64b7..48407f4b 100644
--- a/docker/Dockerfile.agent
+++ b/docker/Dockerfile.agent
@@ -1,4 +1,5 @@
-FROM oven/bun:1 AS builder
+# syntax=docker/dockerfile:1
+FROM --platform=$BUILDPLATFORM oven/bun:1 AS builder
WORKDIR /build
diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp
index 065decf1..2de72f1f 100644
--- a/docker/Dockerfile.mcp
+++ b/docker/Dockerfile.mcp
@@ -1,9 +1,16 @@
# syntax=docker/dockerfile:1
-FROM golang:1.25-alpine AS build
+FROM scratch AS gomodcache
+
+FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
+ --mount=type=bind,from=gomodcache,target=/tmp/gomodcache \
+ set -eux; \
+ if [ -d /tmp/gomodcache/cache/download ]; then \
+ cp -a /tmp/gomodcache/. /go/pkg/mod/; \
+ fi; \
go mod download
COPY . .
diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server
index 8d16ba06..06df6301 100644
--- a/docker/Dockerfile.server
+++ b/docker/Dockerfile.server
@@ -1,37 +1,33 @@
# syntax=docker/dockerfile:1
-# ---- Stage 1: Build server binary ----
-FROM golang:1.25-alpine AS server-builder
+# ---- Stage 0: Cache Context Fallback ----
+FROM scratch AS gomodcache
+# ---- Stage 1: Build base with dependencies ----
+FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build-base
WORKDIR /build
RUN apk add --no-cache git make
-
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
+ --mount=type=bind,from=gomodcache,target=/tmp/gomodcache \
+ set -eux; \
+ if [ -d /tmp/gomodcache/cache/download ]; then \
+ cp -a /tmp/gomodcache/. /go/pkg/mod/; \
+ fi; \
go mod download
-
COPY . .
+# ---- Stage 2: Build server binary ----
+FROM build-base AS server-builder
ARG VERSION=dev
ARG COMMIT_HASH=unknown
ARG BUILD_TIME=unknown
ARG TARGETOS
ARG TARGETARCH
-
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -eux; \
- build_os="${TARGETOS:-linux}"; \
- build_arch="${TARGETARCH:-$(uname -m)}"; \
- case "$build_arch" in \
- x86_64) build_arch="amd64" ;; \
- aarch64) build_arch="arm64" ;; \
- esac; \
- case "$build_arch" in \
- amd64|arm64) ;; \
- *) echo "unsupported TARGETARCH: $build_arch (only amd64/arm64)"; exit 1 ;; \
- esac; \
- CGO_ENABLED=0 GOOS="$build_os" GOARCH="$build_arch" \
+ CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
go build -trimpath \
-ldflags "-s -w \
-X github.com/memohai/memoh/internal/version.Version=${VERSION} \
@@ -39,23 +35,13 @@ RUN --mount=type=cache,target=/go/pkg/mod \
-X github.com/memohai/memoh/internal/version.BuildTime=${BUILD_TIME}" \
-o memoh-server ./cmd/agent/main.go
-# ---- Stage 2: Build MCP binary ----
-FROM golang:1.25-alpine AS mcp-builder
-
-WORKDIR /src
-RUN apk add --no-cache ca-certificates git
-
-COPY go.mod go.sum ./
-RUN --mount=type=cache,target=/go/pkg/mod \
- go mod download
-
-COPY . .
-
-ARG TARGETARCH=amd64
+# ---- Stage 3: Build MCP binary ----
+FROM build-base AS mcp-builder
+ARG TARGETARCH
ARG COMMIT_HASH=unknown
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
- CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
+ CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} \
go build -trimpath \
-ldflags "-s -w -X github.com/memohai/memoh/internal/version.CommitHash=${COMMIT_HASH}" \
-o /out/mcp ./cmd/mcp
@@ -88,14 +74,15 @@ FROM alpine:latest AS oci-exporter
COPY --from=mcp-rootfs /tmp/rootfs.tar /tmp/layer.tar
ARG MCP_IMAGE_TAG=docker.io/library/memoh-mcp:latest
+ARG TARGETARCH
RUN set -e \
&& LAYER_SHA=$(sha256sum /tmp/layer.tar | awk '{print $1}') \
&& LAYER_SIZE=$(wc -c < /tmp/layer.tar) \
&& mkdir -p "/tmp/image/${LAYER_SHA}" /out \
&& mv /tmp/layer.tar "/tmp/image/${LAYER_SHA}/layer.tar" \
- && printf '{"architecture":"amd64","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp image"}]}' \
- "${LAYER_SHA}" > /tmp/config.json \
+ && printf '{"architecture":"%s","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp image"}]}' \
+ "${TARGETARCH:-amd64}" "${LAYER_SHA}" > /tmp/config.json \
&& CONFIG_SHA=$(sha256sum /tmp/config.json | awk '{print $1}') \
&& mv /tmp/config.json "/tmp/image/${CONFIG_SHA}.json" \
&& printf '[{"Config":"%s.json","RepoTags":["%s"],"Layers":["%s/layer.tar"]}]' \
diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web
index cec58def..758127f2 100644
--- a/docker/Dockerfile.web
+++ b/docker/Dockerfile.web
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
-FROM node:25-alpine AS builder
+FROM --platform=$BUILDPLATFORM node:25-alpine AS builder
WORKDIR /build
From 4d668b177ab3f1eb9463d08f092a3112fa837298 Mon Sep 17 00:00:00 2001
From: Ran <16112591+chen-ran@users.noreply.github.com>
Date: Tue, 24 Feb 2026 06:03:26 +0800
Subject: [PATCH 2/8] chore: update docker badge
---
README.md | 1 +
README_CN.md | 1 +
2 files changed, 2 insertions(+)
diff --git a/README.md b/README.md
index a699b940..7565030b 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@
+
[
Telegram Group]
diff --git a/README_CN.md b/README_CN.md
index 6fea3dbb..fa006709 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -15,6 +15,7 @@

+
[
Telegram 群组]
From 0c6a9053170918845500f4fb048919a3a56858d4 Mon Sep 17 00:00:00 2001
From: Ran <16112591+chen-ran@users.noreply.github.com>
Date: Tue, 24 Feb 2026 04:21:18 +0800
Subject: [PATCH 3/8] feat(auth): implement JWT token refresh mechanism
---
agent/src/index.ts | 50 ++++++++++++++++++--
agent/src/modules/chat.ts | 33 ++++++++------
internal/auth/jwt.go | 44 ++++++++++++++++++
internal/auth/jwt_test.go | 96 +++++++++++++++++++++++++++++++++++++++
internal/handlers/auth.go | 33 ++++++++++++++
5 files changed, 237 insertions(+), 19 deletions(-)
create mode 100644 internal/auth/jwt_test.go
diff --git a/agent/src/index.ts b/agent/src/index.ts
index 4ae5c75d..dd69cdb4 100644
--- a/agent/src/index.ts
+++ b/agent/src/index.ts
@@ -3,7 +3,7 @@ import { chatModule } from './modules/chat'
import { corsMiddleware } from './middlewares/cors'
import { errorMiddleware } from './middlewares/error'
import { loadConfig, getBaseUrl as getBaseUrlByConfig } from '@memoh/config'
-import { AuthFetcher } from '@memoh/agent'
+import { AgentAuthContext, AuthFetcher } from '@memoh/agent'
const config = loadConfig('../config.toml')
@@ -11,12 +11,54 @@ export const getBaseUrl = () => {
return getBaseUrlByConfig(config)
}
-export const createAuthFetcher = (bearer: string | undefined): AuthFetcher => {
+function parseJwtExp(token: string): number | null {
+ try {
+ const base64Url = token.split('.')[1]
+ if (!base64Url) return null
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
+ const jsonPayload = Buffer.from(base64, 'base64').toString('utf8')
+ const payload = JSON.parse(jsonPayload)
+ return payload.exp ? payload.exp * 1000 : null
+ } catch (e) {
+ return null
+ }
+}
+
+let refreshPromise: Promise
| null = null
+
+export const createAuthFetcher = (auth: AgentAuthContext): AuthFetcher => {
return async (url: string, options?: RequestInit) => {
+ if (auth.bearer) {
+ const exp = parseJwtExp(auth.bearer)
+ if (exp !== null && exp - Date.now() < 120000) { // Refresh if expiring in < 2 mins
+ if (!refreshPromise) {
+ refreshPromise = (async () => {
+ const refreshUrl = new URL('/auth/refresh', `${getBaseUrl().replace(/\/$/, '')}/`).toString()
+ const res = await fetch(refreshUrl, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${auth.bearer}` }
+ })
+ if (res.ok) {
+ const data = await res.json()
+ return data.access_token
+ }
+ throw new Error('Failed to refresh token')
+ })().finally(() => {
+ refreshPromise = null
+ })
+ }
+ try {
+ auth.bearer = await refreshPromise
+ } catch (e) {
+ console.error('Token refresh failed', e)
+ }
+ }
+ }
+
const requestOptions = options ?? {}
const headers = new Headers(requestOptions.headers || {})
- if (bearer && !headers.has('Authorization')) {
- headers.set('Authorization', `Bearer ${bearer}`)
+ if (auth.bearer && !headers.has('Authorization')) {
+ headers.set('Authorization', `Bearer ${auth.bearer}`)
}
const baseURL = getBaseUrl()
diff --git a/agent/src/modules/chat.ts b/agent/src/modules/chat.ts
index 4758ba91..a70a8c4f 100644
--- a/agent/src/modules/chat.ts
+++ b/agent/src/modules/chat.ts
@@ -25,7 +25,11 @@ export const chatModule = new Elysia({ prefix: '/chat' })
.use(bearerMiddleware)
.post('/', async ({ body, bearer }) => {
console.log('chat', body)
- const authFetcher = createAuthFetcher(bearer)
+ const auth = {
+ bearer: bearer!,
+ baseUrl: getBaseUrl(),
+ }
+ const authFetcher = createAuthFetcher(auth)
const { ask } = createAgent({
model: body.model as ModelConfig,
activeContextTime: body.activeContextTime,
@@ -33,10 +37,7 @@ export const chatModule = new Elysia({ prefix: '/chat' })
currentChannel: body.currentChannel,
allowedActions: body.allowedActions,
identity: body.identity,
- auth: {
- bearer: bearer!,
- baseUrl: getBaseUrl(),
- },
+ auth,
skills: body.usableSkills,
mcpConnections: body.mcpConnections,
inbox: body.inbox,
@@ -55,7 +56,11 @@ export const chatModule = new Elysia({ prefix: '/chat' })
.post('/stream', async function* ({ body, bearer }) {
console.log('stream', body)
try {
- const authFetcher = createAuthFetcher(bearer)
+ const auth = {
+ bearer: bearer!,
+ baseUrl: getBaseUrl(),
+ }
+ const authFetcher = createAuthFetcher(auth)
const { stream } = createAgent({
model: body.model as ModelConfig,
activeContextTime: body.activeContextTime,
@@ -63,10 +68,7 @@ export const chatModule = new Elysia({ prefix: '/chat' })
currentChannel: body.currentChannel,
allowedActions: body.allowedActions,
identity: body.identity,
- auth: {
- bearer: bearer!,
- baseUrl: getBaseUrl(),
- },
+ auth,
skills: body.usableSkills,
mcpConnections: body.mcpConnections,
inbox: body.inbox,
@@ -96,17 +98,18 @@ export const chatModule = new Elysia({ prefix: '/chat' })
})
.post('/trigger-schedule', async ({ body, bearer }) => {
console.log('trigger-schedule', body)
- const authFetcher = createAuthFetcher(bearer)
+ const auth = {
+ bearer: bearer!,
+ baseUrl: getBaseUrl(),
+ }
+ const authFetcher = createAuthFetcher(auth)
const { triggerSchedule } = createAgent({
model: body.model as ModelConfig,
activeContextTime: body.activeContextTime,
channels: body.channels,
currentChannel: body.currentChannel,
identity: body.identity,
- auth: {
- bearer: bearer!,
- baseUrl: getBaseUrl(),
- },
+ auth,
skills: body.usableSkills,
mcpConnections: body.mcpConnections,
inbox: body.inbox,
diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go
index 86854928..340cab23 100644
--- a/internal/auth/jwt.go
+++ b/internal/auth/jwt.go
@@ -159,6 +159,50 @@ func ChatTokenFromContext(c echo.Context) (ChatToken, error) {
return info, nil
}
+// RefreshTokenFromContext extracts the current token from context and issues a new one
+// with the same claims but a renewed expiration time.
+func RefreshTokenFromContext(c echo.Context, secret string, defaultExpiresIn time.Duration) (string, time.Time, error) {
+ token, ok := c.Get("user").(*jwt.Token)
+ if !ok || token == nil || !token.Valid {
+ return "", time.Time{}, echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
+ }
+
+ claims, ok := token.Claims.(jwt.MapClaims)
+ if !ok {
+ return "", time.Time{}, echo.NewHTTPError(http.StatusUnauthorized, "invalid token claims")
+ }
+
+ // Calculate original duration if possible
+ expiresIn := defaultExpiresIn
+ if expRaw, ok := claims["exp"].(float64); ok {
+ if iatRaw, ok := claims["iat"].(float64); ok {
+ duration := time.Duration(expRaw-iatRaw) * time.Second
+ if duration > 0 {
+ expiresIn = duration
+ }
+ }
+ }
+
+ now := time.Now().UTC()
+ expiresAt := now.Add(expiresIn)
+
+ // Create new claims, copying over existing ones but updating time bounds
+ newClaims := jwt.MapClaims{}
+ for k, v := range claims {
+ newClaims[k] = v
+ }
+ newClaims["iat"] = now.Unix()
+ newClaims["exp"] = expiresAt.Unix()
+
+ newToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims)
+ signed, err := newToken.SignedString([]byte(secret))
+ if err != nil {
+ return "", time.Time{}, err
+ }
+
+ return signed, expiresAt, nil
+}
+
func claimString(claims jwt.MapClaims, key string) string {
raw, ok := claims[key]
if !ok || raw == nil {
diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go
new file mode 100644
index 00000000..2908c22e
--- /dev/null
+++ b/internal/auth/jwt_test.go
@@ -0,0 +1,96 @@
+package auth
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/labstack/echo/v4"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRefreshTokenFromContext(t *testing.T) {
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodPost, "/", nil)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ secret := "test-secret"
+ userID := "user-123"
+
+ // Create an initial token with a 5-minute lifespan
+ initialDuration := 5 * time.Minute
+ initialTokenStr, _, err := GenerateToken(userID, secret, initialDuration)
+ assert.NoError(t, err)
+
+ // Parse the token to place it into the echo context
+ token, err := jwt.Parse(initialTokenStr, func(token *jwt.Token) (interface{}, error) {
+ return []byte(secret), nil
+ })
+ assert.NoError(t, err)
+ c.Set("user", token)
+
+ // Simulate some time passing to ensure the new token has a different 'iat' and 'exp'
+ time.Sleep(1 * time.Second)
+
+ // Run the refresh function
+ defaultDuration := 1 * time.Hour
+ newTokenStr, newExpiresAt, err := RefreshTokenFromContext(c, secret, defaultDuration)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, newTokenStr)
+
+ // Parse the original token claims for comparison
+ originalClaims, ok := token.Claims.(jwt.MapClaims)
+ assert.True(t, ok)
+ origIat := int64(originalClaims["iat"].(float64))
+ origExp := int64(originalClaims["exp"].(float64))
+
+ // Parse the new token
+ newToken, err := jwt.Parse(newTokenStr, func(token *jwt.Token) (interface{}, error) {
+ return []byte(secret), nil
+ })
+ assert.NoError(t, err)
+ assert.True(t, newToken.Valid)
+
+ newClaims, ok := newToken.Claims.(jwt.MapClaims)
+ assert.True(t, ok)
+
+ // Ensure standard payload claims are retained
+ assert.Equal(t, userID, newClaims[claimSubject])
+ assert.Equal(t, userID, newClaims[claimUserID])
+
+ // Validate the new time bounds
+ newIat := int64(newClaims["iat"].(float64))
+ newExp := int64(newClaims["exp"].(float64))
+
+ // 1. Ensure time has advanced
+ assert.Greater(t, newIat, origIat)
+
+ // 2. Ensure it calculated the original duration and used it (5 mins), NOT the default 1 hour
+ assert.Equal(t, newExp-newIat, origExp-origIat)
+ assert.Equal(t, int64(5*60), newExp-newIat)
+
+ // 3. Ensure the return value matches the claim
+ assert.Equal(t, newExpiresAt.Unix(), newExp)
+}
+
+func TestRefreshTokenFromContext_MissingUser(t *testing.T) {
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodPost, "/", nil)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ secret := "test-secret"
+ defaultDuration := 1 * time.Hour
+
+ // Context without the "user" key
+ _, _, err := RefreshTokenFromContext(c, secret, defaultDuration)
+ assert.Error(t, err)
+
+ httpErr, ok := err.(*echo.HTTPError)
+ assert.True(t, ok)
+ assert.Equal(t, http.StatusUnauthorized, httpErr.Code)
+ assert.Equal(t, "invalid token", httpErr.Message)
+}
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index dfb4503d..d3deb6f8 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -46,6 +46,7 @@ func NewAuthHandler(log *slog.Logger, accountService *accounts.Service, jwtSecre
func (h *AuthHandler) Register(e *echo.Echo) {
e.POST("/auth/login", h.Login)
+ e.POST("/auth/refresh", h.Refresh)
}
// Login godoc
@@ -103,3 +104,35 @@ func (h *AuthHandler) Login(c echo.Context) error {
DisplayName: account.DisplayName,
})
}
+
+type RefreshResponse struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresAt string `json:"expires_at"`
+}
+
+// Refresh godoc
+// @Summary Refresh Token
+// @Description Issue a new JWT using the existing claims with updated expiration
+// @Tags auth
+// @Security BearerAuth
+// @Success 200 {object} RefreshResponse
+// @Failure 401 {object} ErrorResponse
+// @Failure 500 {object} ErrorResponse
+// @Router /auth/refresh [post]
+func (h *AuthHandler) Refresh(c echo.Context) error {
+ if strings.TrimSpace(h.jwtSecret) == "" {
+ return echo.NewHTTPError(http.StatusInternalServerError, "jwt secret not configured")
+ }
+
+ token, expiresAt, err := auth.RefreshTokenFromContext(c, h.jwtSecret, h.expiresIn)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusUnauthorized, err.Error())
+ }
+
+ return c.JSON(http.StatusOK, RefreshResponse{
+ AccessToken: token,
+ TokenType: "Bearer",
+ ExpiresAt: expiresAt.Format(time.RFC3339),
+ })
+}
From 8bbdb2b0225d2f704fc64a966f42aef736c88053 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=A8=E8=8B=92?=
<16112591+chen-ran@users.noreply.github.com>
Date: Tue, 24 Feb 2026 04:49:01 +0800
Subject: [PATCH 4/8] Update agent/src/index.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
agent/src/index.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/agent/src/index.ts b/agent/src/index.ts
index dd69cdb4..c34c9599 100644
--- a/agent/src/index.ts
+++ b/agent/src/index.ts
@@ -24,9 +24,8 @@ function parseJwtExp(token: string): number | null {
}
}
-let refreshPromise: Promise | null = null
-
export const createAuthFetcher = (auth: AgentAuthContext): AuthFetcher => {
+ let refreshPromise: Promise | null = null
return async (url: string, options?: RequestInit) => {
if (auth.bearer) {
const exp = parseJwtExp(auth.bearer)
From 54b337e39152a08f73dc8ea3b63a16eb2ed260f6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=A8=E8=8B=92?=
<16112591+chen-ran@users.noreply.github.com>
Date: Tue, 24 Feb 2026 04:49:28 +0800
Subject: [PATCH 5/8] Update agent/src/index.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
agent/src/index.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/agent/src/index.ts b/agent/src/index.ts
index c34c9599..515d6876 100644
--- a/agent/src/index.ts
+++ b/agent/src/index.ts
@@ -50,6 +50,7 @@ export const createAuthFetcher = (auth: AgentAuthContext): AuthFetcher => {
auth.bearer = await refreshPromise
} catch (e) {
console.error('Token refresh failed', e)
+ throw e
}
}
}
From 579bf45fc259218f49ef724258850167c9521389 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=A8=E8=8B=92?=
<16112591+chen-ran@users.noreply.github.com>
Date: Tue, 24 Feb 2026 04:59:08 +0800
Subject: [PATCH 6/8] Update internal/auth/jwt_test.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
internal/auth/jwt_test.go | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go
index 2908c22e..5ccbf8ba 100644
--- a/internal/auth/jwt_test.go
+++ b/internal/auth/jwt_test.go
@@ -68,9 +68,10 @@ func TestRefreshTokenFromContext(t *testing.T) {
// 1. Ensure time has advanced
assert.Greater(t, newIat, origIat)
- // 2. Ensure it calculated the original duration and used it (5 mins), NOT the default 1 hour
- assert.Equal(t, newExp-newIat, origExp-origIat)
- assert.Equal(t, int64(5*60), newExp-newIat)
+ // 2. Ensure the refreshed token has a positive lifetime and does not exceed the configured default duration
+ lifetimeSeconds := newExp - newIat
+ assert.Greater(t, lifetimeSeconds, int64(0))
+ assert.LessOrEqual(t, lifetimeSeconds, int64(defaultDuration.Seconds()))
// 3. Ensure the return value matches the claim
assert.Equal(t, newExpiresAt.Unix(), newExp)
From 3e3379a869effb10959a89ec6fa2d996545cdb56 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=A8=E8=8B=92?=
<16112591+chen-ran@users.noreply.github.com>
Date: Tue, 24 Feb 2026 05:04:36 +0800
Subject: [PATCH 7/8] Update agent/src/index.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
agent/src/index.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/agent/src/index.ts b/agent/src/index.ts
index 515d6876..8a82c950 100644
--- a/agent/src/index.ts
+++ b/agent/src/index.ts
@@ -20,6 +20,7 @@ function parseJwtExp(token: string): number | null {
const payload = JSON.parse(jsonPayload)
return payload.exp ? payload.exp * 1000 : null
} catch (e) {
+ console.error('Failed to parse JWT expiration from token', e)
return null
}
}
From eeef8a46c2c3e388c11686f86c9cc8d263e5aa5b Mon Sep 17 00:00:00 2001
From: Ran <16112591+chen-ran@users.noreply.github.com>
Date: Tue, 24 Feb 2026 16:58:04 +0800
Subject: [PATCH 8/8] chore(ci): update github action
---
.github/workflows/docker.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 53f765b9..d22b5e1f 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -2,7 +2,7 @@ name: Docker
on:
push:
- branches: [main]
+ branches: [main, "v*.*"]
tags: ["v*"]
paths-ignore:
- "docs/**"
@@ -11,7 +11,7 @@ on:
release:
types: [published]
pull_request:
- branches: [main]
+ branches: [main, "v*.*"]
paths-ignore:
- "docs/**"
- "**.md"
@@ -23,7 +23,7 @@ concurrency:
cancel-in-progress: true
env:
- PUSH: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') }}
+ PUSH: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') }}
REGISTRY: ghcr.io
permissions:
@@ -126,7 +126,7 @@ jobs:
merge:
runs-on: ubuntu-latest
- if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release')
+ if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release')
needs: build
strategy:
matrix: