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
+2 -2
View File
@@ -23,13 +23,13 @@
"@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"deepmerge-ts": "^8.0.1",
"deepmerge-ts": "^8.0.2",
"jsonrepair": "^3.15.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"ai": "^7.0.66",
"ai": "^7.0.77",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2"
}
+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/ai",
+16 -16
View File
@@ -21,20 +21,20 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.39",
"@ai-sdk/cerebras": "^3.0.31",
"@ai-sdk/cohere": "^4.0.27",
"@ai-sdk/deepseek": "^3.0.28",
"@ai-sdk/fireworks": "^3.0.33",
"@ai-sdk/google": "^4.0.44",
"@ai-sdk/groq": "^4.0.28",
"@ai-sdk/mistral": "^4.0.29",
"@ai-sdk/openai": "^4.0.42",
"@ai-sdk/openai-compatible": "^3.0.31",
"@ai-sdk/perplexity": "^4.0.29",
"@ai-sdk/togetherai": "^3.0.32",
"@ai-sdk/xai": "^4.0.40",
"@aws-sdk/client-s3": "^3.1111.0",
"@ai-sdk/anthropic": "^4.0.41",
"@ai-sdk/cerebras": "^3.0.35",
"@ai-sdk/cohere": "^4.0.29",
"@ai-sdk/deepseek": "^3.0.31",
"@ai-sdk/fireworks": "^3.0.38",
"@ai-sdk/google": "^4.0.50",
"@ai-sdk/groq": "^4.0.30",
"@ai-sdk/mistral": "^4.0.32",
"@ai-sdk/openai": "^4.0.46",
"@ai-sdk/openai-compatible": "^3.0.35",
"@ai-sdk/perplexity": "^4.0.31",
"@ai-sdk/togetherai": "^3.0.36",
"@ai-sdk/xai": "^4.0.43",
"@aws-sdk/client-s3": "^3.1116.0",
"@orpc/client": "^1.15.0",
"@orpc/experimental-ratelimit": "^1.15.0",
"@orpc/server": "^1.15.0",
@@ -46,9 +46,9 @@
"@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"ai": "^7.0.66",
"ai": "^7.0.77",
"bcrypt": "^6.0.0",
"better-auth": "1.6.29",
"better-auth": "1.7.1",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.51.0",
+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/api",
+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",
+1
View File
@@ -14,6 +14,7 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noUncheckedIndexedAccess": true,
"noUncheckedSideEffectImports": true,
"noUnusedLocals": true,
+1 -1
View File
@@ -30,7 +30,7 @@
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/pg": "^8.23.0",
"@types/pg": "^8.23.1",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"drizzle-kit": "1.0.0-rc.4",
"typescript": "^7.0.2"
+10 -1
View File
@@ -78,6 +78,10 @@ export const account = pg.pgTable(
.$defaultFn(() => generateId()),
accountId: pg.text("account_id").notNull(),
providerId: pg.text("provider_id").notNull().default("credential"),
// Better Auth 1.7 scopes account identity to (issuer, accountId) rather than providerId.
// Real OIDC issuers are stored verbatim; providers without one get a synthetic
// `local:`/`local:oauth:` namespace. See migrations/*_account_issuer for the backfill.
issuer: pg.text("issuer").notNull(),
userId: pg
.text("user_id")
.notNull()
@@ -100,7 +104,7 @@ export const account = pg.pgTable(
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(t) => [pg.index().on(t.userId)],
(t) => [pg.index().on(t.userId), pg.uniqueIndex("account_issuer_account_id_unique_idx").on(t.issuer, t.accountId)],
);
export const verification = pg.pgTable(
@@ -236,6 +240,11 @@ export const jwks = pg.pgTable("jwks", {
privateKey: pg.text("private_key").notNull(),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
expiresAt: pg.timestamp("expires_at", { withTimezone: true }),
// Better Auth 1.7 added `alg` and `crv` to the jwt plugin's jwks model. Both are optional to
// the plugin, but the Drizzle adapter rejects a model whose columns it cannot find, so every
// session verification throws until they exist. Existing rows keep NULL and stay valid.
alg: pg.text("alg"),
crv: pg.text("crv"),
});
export const oauthClient = pg.pgTable(
+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/db",
+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/docx",
+4 -4
View File
@@ -57,15 +57,15 @@
},
"devDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
"@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.8",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8",
"@deepseek-ai/schemastery": "^3.18.1",
"@reactive-resume/config": "workspace:*",
"@reactive-resume/mcp": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"tsdown": "^0.22.0",
"tsdown": "^0.22.14",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
},
"dsh": {
"bundle": {
+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: "dsh-plugin-reactive-resume",
+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/email",
+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/env",
+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/fonts",
+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/import",
+1 -1
View File
@@ -29,6 +29,6 @@
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
}
}
+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/mcp",
+3 -3
View File
@@ -24,7 +24,7 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@react-pdf/renderer": "^4.6.1",
"@react-pdf/renderer": "^4.8.1",
"@reactive-resume/fonts": "workspace:*",
"@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*",
@@ -36,8 +36,8 @@
"react-pdf-html": "^2.1.5"
},
"devDependencies": {
"@napi-rs/canvas": "1.0.6",
"@react-pdf/types": "^2.11.3",
"@napi-rs/canvas": "1.0.8",
"@react-pdf/types": "^2.13.1",
"@reactive-resume/config": "workspace:*",
"@types/react": "^19.2.18",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
+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";
const config = createVitestProjectConfig({
name: "@reactive-resume/pdf",
+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/resume",
+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/schema",
+2 -2
View File
@@ -29,7 +29,7 @@
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-resizable-panels": "^4.12.3",
"shadcn": "^4.18.0",
"shadcn": "^4.19.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
@@ -37,7 +37,7 @@
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/react-dom": "^19.2.5",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"postcss": "^8.5.26",
"tailwindcss": "^4.3.3",
+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/ui",
+1 -1
View File
@@ -27,7 +27,7 @@
"clsx": "^2.1.1",
"tailwind-merge": "^3.6.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.1",
"uuid": "^14.0.2",
"zod": "^4.4.3"
},
"devDependencies": {
+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/utils",