Release v5.2.8 (#3375)

Upgrades to Better Auth 1.7, expands Custom Styles coverage of item headers, and adds a human-approval step to the AI agent's resume edits.

Breaking for self-hosters using a custom OAuth provider: the callback path changes from /api/auth/oauth2/callback/custom to /api/auth/callback/custom, and installs using OAUTH_DISCOVERY_URL need one additional UPDATE after upgrading. Both are documented in docs/self-hosting/sso.mdx.

- Better Auth 1.7, with the account issuer migration and the jwks alg/crv columns the 1.7 jwt plugin requires
- Agent edits gated behind an approval step, with crash-safe runs and context pruning
- item-header now covers every section header row on every template; adds the item-header-row part
- Fixes provider unlinking, auth error messages, and version conflicts on freshly created resumes
- New /auth/error page, translated across all 53 target locales
- DeepSeek Harness plugin moved into packages/dsh-plugin
- Dependency bumps across the workspace
This commit is contained in:
Amruth Pillai
2026-08-24 21:44:16 +02:00
committed by GitHub
parent 3221afda9d
commit 3c195dc3f8
160 changed files with 17743 additions and 3892 deletions
+7 -7
View File
@@ -15,19 +15,19 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@better-auth/api-key": "^1.6.29",
"@better-auth/drizzle-adapter": "^1.6.29",
"@better-auth/infra": "^0.3.7",
"@better-auth/oauth-provider": "^1.6.29",
"@better-auth/passkey": "^1.6.29",
"@better-auth/api-key": "^1.7.1",
"@better-auth/drizzle-adapter": "^1.7.1",
"@better-auth/infra": "^0.4.2",
"@better-auth/oauth-provider": "^1.7.1",
"@better-auth/passkey": "^1.7.1",
"@reactive-resume/db": "workspace:*",
"@reactive-resume/email": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"bcrypt": "^6.0.0",
"better-auth": "1.6.29",
"better-auth": "1.7.1",
"drizzle-orm": "1.0.0-rc.4",
"jose": "^6.2.9",
"jose": "^6.2.10",
"react": "^19.2.8"
},
"devDependencies": {
+2
View File
@@ -6,7 +6,9 @@ describe("social provider signup policy", () => {
it.each(["google", "github", "linkedin"] as const)(
"allows implicit signup through %s while honoring the global signup restriction",
(provider) => {
// Better Auth 1.7 allows a lazy `() => config` form; ours are always static objects.
const config = auth.options.socialProviders?.[provider];
if (typeof config === "function") throw new TypeError(`${provider} provider config should be a static object`);
expect(config).not.toHaveProperty("disableImplicitSignUp");
expect(config?.disableSignUp).toBe(env.FLAG_DISABLE_SIGNUPS);
+45 -9
View File
@@ -1,4 +1,4 @@
import type { GenericOAuthConfig } from "better-auth/plugins";
import type { GenericOAuthConfig, GenericOAuthUserInfo } from "better-auth/plugins";
import type { JWTPayload } from "jose";
import { apiKey } from "@better-auth/api-key";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
@@ -8,7 +8,7 @@ import { passkey } from "@better-auth/passkey";
import { compare, hash } from "bcrypt";
import { APIError, betterAuth } from "better-auth";
import { createAuthMiddleware } from "better-auth/api";
import { verifyAccessToken } from "better-auth/oauth2";
import { verifyBearerToken } from "better-auth/oauth2";
import { admin, jwt } from "better-auth/plugins";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { twoFactor } from "better-auth/plugins/two-factor";
@@ -54,7 +54,7 @@ const OAUTH_AUDIENCES = [
];
export function verifyOAuthToken(token: string): Promise<JWTPayload> {
return verifyAccessToken(token, {
return verifyBearerToken(token, {
jwksUrl: `${internalBaseUrl}/api/auth/jwks`,
verifyOptions: {
issuer: `${authBaseUrl}/api/auth`,
@@ -83,6 +83,34 @@ const oauthProviderRateLimit = isRateLimitEnabled
userinfo: false,
} as const);
// Better Auth 1.7 types generic-OAuth profile extras as `unknown`.
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
// `@better-auth/oauth-provider@1.7.1` declares OpenAPI parameter metadata (`schema.items`) in a
// shape that is not `exactOptionalPropertyTypes`-clean, which stops the plugin from structurally
// satisfying `BetterAuthPlugin`. `metadata` only feeds doc generation, so dropping it from the
// endpoint types keeps request/response inference (`auth.api.*`) intact. Remove once upstream ships
// EOPT-compatible endpoint types.
type WithoutEndpointMetadata<TPlugin> = TPlugin extends { endpoints: infer TEndpoints }
? Omit<TPlugin, "endpoints"> & {
endpoints: {
[K in keyof TEndpoints]: TEndpoints[K] extends {
(...args: infer TArgs): infer TResult;
options: infer TOptions;
path: infer TPath;
}
? {
(...args: TArgs): TResult;
options: Omit<TOptions, "metadata">;
path: TPath;
}
: TEndpoints[K];
};
}
: TPlugin;
const getAuthConfig = () => {
const authConfigs: GenericOAuthConfig[] = [];
@@ -97,12 +125,15 @@ const getAuthConfig = () => {
tokenUrl: env.OAUTH_TOKEN_URL,
userInfoUrl: env.OAUTH_USER_INFO_URL,
scopes: env.OAUTH_SCOPES,
redirectURI: `${authBaseUrl}/api/auth/oauth2/callback/custom`,
mapProfileToUser: createProfileMapper({
// Better Auth 1.7 folds generic OAuth providers into `socialProviders`, so the callback
// is served by `/callback/:id` — the old `/oauth2/callback/:id` route no longer exists.
redirectURI: `${authBaseUrl}/api/auth/callback/custom`,
mapProfileToUser: createProfileMapper<GenericOAuthUserInfo>({
providerName: "OAuth Provider",
getPreferredUsername: (profile, context) => profile.preferred_username ?? context.emailLocalPart,
getName: (profile, context) => profile.name ?? profile.preferred_username ?? context.emailLocalPart,
getImage: (profile) => profile.image ?? profile.picture ?? profile.avatar_url,
getPreferredUsername: (profile, context) => asString(profile.preferred_username) ?? context.emailLocalPart,
getName: (profile, context) =>
asString(profile.name) ?? asString(profile.preferred_username) ?? context.emailLocalPart,
getImage: (profile) => asString(profile.image) ?? asString(profile.picture) ?? asString(profile.avatar_url),
}),
} satisfies GenericOAuthConfig);
}
@@ -146,6 +177,11 @@ const getAuthConfig = () => {
}),
},
// Without this, OAuth callback failures land on Better Auth's built-in `/api/auth/error`
// page. It also backs the `oauthProvider` plugin's authorization errors that happen before
// `redirect_uri` is validated and so cannot be returned to the requesting client.
onAPIError: { errorURL: "/auth/error" },
advanced: {
database: { generateId },
useSecureCookies: authBaseUrl.startsWith("https://"),
@@ -273,7 +309,7 @@ const getAuthConfig = () => {
allowUnauthenticatedClientRegistration: true,
rateLimit: oauthProviderRateLimit,
silenceWarnings: { oauthAuthServerConfig: true },
}),
}) as WithoutEndpointMetadata<ReturnType<typeof oauthProvider>>,
username({
minUsernameLength: 3,
maxUsernameLength: 64,
-3
View File
@@ -75,7 +75,6 @@ describe("createProfileMapper", () => {
expect(dbMock.update).toHaveBeenCalledTimes(1);
expect(dbMock.updateSet).toHaveBeenCalledWith({ email: "legacy.user@example.com" });
expect(result).toEqual({
id: "user-1",
name: "Legacy User",
email: "legacy.user@example.com",
image: "https://example.com/new.png",
@@ -153,7 +152,6 @@ describe("createProfileMapper", () => {
expect(dbMock.select).toHaveBeenCalledTimes(2);
expect(result).toEqual({
id: "user-1",
name: "GitHub User",
email: "github.user@example.com",
image: "https://example.com/new.png",
@@ -190,7 +188,6 @@ describe("createProfileMapper", () => {
expect(dbMock.update).toHaveBeenCalledTimes(1);
expect(dbMock.updateSet).toHaveBeenCalledWith({ email: "legacy.user@example.com" });
expect(result).toEqual({
id: "user-1",
name: "Legacy User",
email: "legacy.user@example.com",
image: "https://example.com/new.png",
+14 -11
View File
@@ -137,14 +137,14 @@ async function findAvailableUsernameSuffix(baseUsername: string, index = 1): Pro
}
interface OAuthProfile {
email?: string | null;
id?: string | number | null;
name?: string | null;
picture?: string | null;
image?: string | null;
avatar_url?: string | null;
login?: string | null;
preferred_username?: string | null;
email?: string | null | undefined;
id?: string | number | null | undefined;
name?: string | null | undefined;
picture?: string | null | undefined;
image?: string | null | undefined;
avatar_url?: string | null | undefined;
login?: string | null | undefined;
preferred_username?: string | null | undefined;
}
interface OAuthMapperContext {
@@ -185,11 +185,14 @@ export function createProfileMapper<TProfile extends OAuthProfile>({
const existingEmail = existingUser.email.trim().toLowerCase();
await normalizeExistingUserEmail(existingUser.id, existingUser.email, existingEmail);
// Better Auth 1.7 forbids `mapProfileToUser` from returning `id`; provider identity is
// resolved by `accountSubject` and existing local users are matched by `account.accountLinking`.
const existingImage = image ?? existingUser.image;
return {
id: existingUser.id,
name: existingUser.name,
email: existingEmail,
image: image ?? existingUser.image,
...(existingImage ? { image: existingImage } : {}),
username: existingUser.username,
displayUsername: existingUser.displayUsername,
emailVerified: existingUser.emailVerified,
@@ -203,7 +206,7 @@ export function createProfileMapper<TProfile extends OAuthProfile>({
return {
name: mappedName || username || emailLocalPart,
email,
image,
...(image ? { image } : {}),
username,
displayUsername: username,
emailVerified: true,
+1 -1
View File
@@ -1,6 +1,6 @@
import { fileURLToPath } from "node:url";
// @boundaries-ignore root shared Vitest config
import { createVitestProjectConfig } from "../../vitest.shared";
import { createVitestProjectConfig } from "../../vitest.shared.mts";
export default createVitestProjectConfig({
name: "@reactive-resume/auth",