mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-25 15:52:21 +10:00
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:
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user