diff --git a/apps/web/src/components/admin/settings/security/identity-providers/__tests__/provider-editor.test.tsx b/apps/web/src/components/admin/settings/security/identity-providers/__tests__/provider-editor.test.tsx index dad8973f3..f4b91b309 100644 --- a/apps/web/src/components/admin/settings/security/identity-providers/__tests__/provider-editor.test.tsx +++ b/apps/web/src/components/admin/settings/security/identity-providers/__tests__/provider-editor.test.tsx @@ -91,6 +91,7 @@ function makeProvider(over: Partial): IdentityProvider { autoCreateUsers: true, autoProvisionRole: 'user', attributeMapping: null, + profileMapping: null, showButton: false, detailsChangedAt: null, lastSuccessfulTestAt: null, diff --git a/apps/web/src/components/admin/settings/security/identity-providers/__tests__/provider-list.test.tsx b/apps/web/src/components/admin/settings/security/identity-providers/__tests__/provider-list.test.tsx index fa7deed14..20c6f7506 100644 --- a/apps/web/src/components/admin/settings/security/identity-providers/__tests__/provider-list.test.tsx +++ b/apps/web/src/components/admin/settings/security/identity-providers/__tests__/provider-list.test.tsx @@ -106,6 +106,7 @@ function makeProvider(over: Partial): IdentityProvider { autoCreateUsers: true, autoProvisionRole: 'user', attributeMapping: null, + profileMapping: null, showButton: false, detailsChangedAt: null, lastSuccessfulTestAt: null, diff --git a/apps/web/src/lib/server/auth/__tests__/provider-registration.test.ts b/apps/web/src/lib/server/auth/__tests__/provider-registration.test.ts index 96b69a2d4..dbc0aad90 100644 --- a/apps/web/src/lib/server/auth/__tests__/provider-registration.test.ts +++ b/apps/web/src/lib/server/auth/__tests__/provider-registration.test.ts @@ -1,7 +1,13 @@ -import { describe, it, expect } from 'vitest' -import { buildGenericOAuthConfigs } from '../build-oauth-configs' +import { describe, it, expect, afterEach, vi } from 'vitest' +import { buildGenericOAuthConfigs, buildProfileMappingGetUserInfo } from '../build-oauth-configs' import { getAllAuthProviders } from '../auth-providers' +/** Unsigned JWT with the given payload — the login path never verifies. */ +function fakeJwt(payload: Record): string { + const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url') + return `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.sig` +} + describe('buildGenericOAuthConfigs', () => { it('registers one config per enabled provider under its registrationId', async () => { const cfgs = await buildGenericOAuthConfigs({ @@ -54,6 +60,161 @@ describe('buildGenericOAuthConfigs', () => { }) }) +describe('profile mapping (getUserInfo)', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('attaches getUserInfo only when the provider has a profileMapping', async () => { + const cfgs = await buildGenericOAuthConfigs({ + providers: [ + { + id: 'idp_plain', + registrationId: 'oidc_plain', + enabled: true, + autoCreateUsers: true, + discoveryUrl: 'https://x/.well-known/openid-configuration', + }, + { + id: 'idp_eve', + registrationId: 'oidc_eve', + enabled: true, + autoCreateUsers: true, + discoveryUrl: 'https://login.eveonline.com/.well-known/openid-configuration', + profileMapping: { source: 'accessTokenJwt' }, + }, + ] as any, + creds: async () => ({ clientId: 'c', clientSecret: 's' }), + tierAllowsOidc: true, + }) + expect(cfgs).toHaveLength(2) + expect(cfgs.find((c) => c.providerId === 'oidc_plain')?.getUserInfo).toBeUndefined() + expect(cfgs.find((c) => c.providerId === 'oidc_eve')?.getUserInfo).toBeTypeOf('function') + }) + + it('accessTokenJwt: decodes the access token and synthesizes a sanitized fallback email', async () => { + const getUserInfo = buildProfileMappingGetUserInfo( + { userInfoUrl: null, discoveryUrl: 'https://login.eveonline.com/.well-known/oidc' }, + { source: 'accessTokenJwt', emailFallback: '{id}@eve.example.com' } + ) + const user = await getUserInfo({ + accessToken: fakeJwt({ + sub: 'CHARACTER:EVE:2119123456', + name: 'Some Pilot', + owner: 'abc123=', + }), + }) + expect(user).toMatchObject({ + id: 'CHARACTER:EVE:2119123456', + name: 'Some Pilot', + email: 'character.eve.2119123456@eve.example.com', + emailVerified: true, + }) + // Raw claims are spread through for mapProfileToUser consumers. + expect(user?.owner).toBe('abc123=') + }) + + it('accessTokenJwt: prefers a real email claim over the fallback', async () => { + const getUserInfo = buildProfileMappingGetUserInfo( + { userInfoUrl: null, discoveryUrl: null }, + { source: 'accessTokenJwt', emailFallback: '{id}@sso.example.com' } + ) + const user = await getUserInfo({ + accessToken: fakeJwt({ sub: 'u1', name: 'N', email: 'real@example.com' }), + }) + expect(user?.email).toBe('real@example.com') + // Claim-sourced email is only verified when the IdP says so. + expect(user?.emailVerified).toBe(false) + }) + + it('accessTokenJwt: returns null on a malformed token or missing id claim', async () => { + const getUserInfo = buildProfileMappingGetUserInfo( + { userInfoUrl: null, discoveryUrl: null }, + { source: 'accessTokenJwt' } + ) + expect(await getUserInfo({ accessToken: 'not-a-jwt' })).toBeNull() + expect(await getUserInfo({})).toBeNull() + expect(await getUserInfo({ accessToken: fakeJwt({ name: 'no sub here' }) })).toBeNull() + }) + + it('accessTokenJwt: omits email when absent and no fallback is configured', async () => { + // Better-Auth then reports the accurate email_is_missing error. + const getUserInfo = buildProfileMappingGetUserInfo( + { userInfoUrl: null, discoveryUrl: null }, + { source: 'accessTokenJwt' } + ) + const user = await getUserInfo({ accessToken: fakeJwt({ sub: 'u1', name: 'N' }) }) + expect(user?.id).toBe('u1') + expect(user?.email).toBeUndefined() + }) + + it('userinfo: fetches the row userInfoUrl with the bearer token and maps custom claim paths', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + CharacterID: 2119123456, + CharacterName: 'Some Pilot', + email_verified: true, + contact: { email: 'pilot@example.com' }, + }), + })) + vi.stubGlobal('fetch', fetchMock) + + const getUserInfo = buildProfileMappingGetUserInfo( + { userInfoUrl: 'https://idp.example.com/userinfo', discoveryUrl: null }, + { + source: 'userinfo', + idClaim: 'CharacterID', + nameClaim: 'CharacterName', + emailClaim: 'contact.email', + } + ) + const user = await getUserInfo({ accessToken: 'opaque-token' }) + expect(fetchMock).toHaveBeenCalledWith('https://idp.example.com/userinfo', { + headers: { authorization: 'Bearer opaque-token' }, + }) + expect(user).toMatchObject({ + id: '2119123456', + name: 'Some Pilot', + email: 'pilot@example.com', + emailVerified: true, + }) + }) + + it('userinfo: resolves the endpoint from discovery once and caches it', async () => { + const fetchMock = vi.fn(async (url: string) => + url === 'https://idp.example.com/.well-known/openid-configuration' + ? { ok: true, json: async () => ({ userinfo_endpoint: 'https://idp.example.com/me' }) } + : { ok: true, json: async () => ({ sub: 'u1', name: 'N', email: 'e@example.com' }) } + ) + vi.stubGlobal('fetch', fetchMock) + + const getUserInfo = buildProfileMappingGetUserInfo( + { + userInfoUrl: null, + discoveryUrl: 'https://idp.example.com/.well-known/openid-configuration', + }, + { source: 'userinfo' } + ) + expect((await getUserInfo({ accessToken: 't1' }))?.id).toBe('u1') + expect((await getUserInfo({ accessToken: 't2' }))?.id).toBe('u1') + // 1 discovery fetch + 2 userinfo fetches — discovery cached after the first. + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('userinfo: returns null when the userinfo fetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false, json: async () => ({}) })) + ) + const getUserInfo = buildProfileMappingGetUserInfo( + { userInfoUrl: 'https://idp.example.com/userinfo', discoveryUrl: null }, + { source: 'userinfo' } + ) + expect(await getUserInfo({ accessToken: 't' })).toBeNull() + }) +}) + describe('social provider registration regression (H3)', () => { it('still exposes the 10 built-in social providers for the social loop', () => { // After OIDC moved to the identity_provider list, the only diff --git a/apps/web/src/lib/server/auth/build-oauth-configs.ts b/apps/web/src/lib/server/auth/build-oauth-configs.ts index 8b6439f16..a898332b0 100644 --- a/apps/web/src/lib/server/auth/build-oauth-configs.ts +++ b/apps/web/src/lib/server/auth/build-oauth-configs.ts @@ -20,6 +20,7 @@ */ import type { IdentityProvider } from '@/lib/server/domains/settings/identity-providers.service' +import type { IdentityProviderProfileMapping } from '@/lib/server/db' /** * Default OIDC scopes requested when a provider has no explicit `scopes`. @@ -28,6 +29,19 @@ import type { IdentityProvider } from '@/lib/server/domains/settings/identity-pr */ export const DEFAULT_OIDC_SCOPES = ['openid', 'email', 'profile'] as const +/** + * Profile shape returned by a custom `getUserInfo`. The mapped identity + * fields satisfy Better-Auth's `OAuth2UserInfo`; the raw claims are spread + * alongside so `mapProfileToUser` (locale) still sees them. + */ +export type MappedUserInfo = { + id: string + name?: string + email?: string + image?: string + emailVerified: boolean +} & Record + /** A single entry in the genericOAuth plugin's `config` array. */ export interface GenericOAuthConfig { providerId: string @@ -39,6 +53,16 @@ export interface GenericOAuthConfig { authorizationUrl?: string tokenUrl?: string scopes?: string[] + /** + * Custom user-info resolution, attached only when the provider row has a + * `profile_mapping`. Replaces Better-Auth's id_token/userinfo default for + * IdPs whose claims don't fit it (e.g. EVE Online: no id_token, no email, + * identity in the JWT access token). + */ + getUserInfo?: (tokens: { + accessToken?: string + idToken?: string + }) => Promise mapProfileToUser?: (profile: unknown) => Record // Force the IdP account picker so admins notice when they're already // signed in as a different identity. @@ -67,6 +91,121 @@ export type ProviderCredentials = { discoveryUrl?: string } | null +/** Resolve a dotted claim path (same convention as `attributeMapping.claimPath`). */ +function resolveClaim(claims: Record, path: string): unknown { + // Namespaced claims (e.g. "https://acme.com/roles") contain dots that are + // not path separators — prefer an exact key match before splitting. + if (path in claims) return claims[path] + let current: unknown = claims + for (const segment of path.split('.')) { + if (current === null || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return current +} + +/** Decode a JWT's payload without verification — mirrors how Better-Auth's + * generic-oauth login path treats id_tokens (bare `decodeJwt`). The token + * was just received first-hand from the IdP's token endpoint over TLS, so + * possession is the trust anchor; there is no third-party token to verify. */ +function decodeJwtPayload(token: string): Record | null { + const parts = token.split('.') + if (parts.length !== 3) return null + try { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) + return payload !== null && typeof payload === 'object' + ? (payload as Record) + : null + } catch { + return null + } +} + +/** + * Synthesize an address from the `emailFallback` template. The `{id}` + * placeholder is sanitized to `[a-z0-9._-]` so a structured id like EVE's + * `CHARACTER:EVE:2119…` becomes a valid local part (`character.eve.2119…`). + */ +function synthesizeEmail(template: string, id: string): string { + const sanitized = id + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '.') + .replace(/^\.+|\.+$/g, '') + return template.replaceAll('{id}', sanitized) +} + +/** + * Build the custom `getUserInfo` for a provider with a `profile_mapping`. + * + * Claim source is either the JWT access token's payload (`accessTokenJwt`) + * or the provider's userinfo endpoint (`userinfo` — the row's `userInfoUrl`, + * else resolved from the discovery document once and cached). Identity + * fields resolve via the configured claim paths; a missing email falls back + * to the `emailFallback` template (such users are marked emailVerified — + * there is no real inbox to verify). Returns null (→ Better-Auth's + * `user_info_is_missing` redirect) when claims can't be obtained or the id + * claim is absent. + */ +export function buildProfileMappingGetUserInfo( + provider: Pick, + mapping: IdentityProviderProfileMapping +): NonNullable { + // Discovery resolution is once-per-auth-instance: the closure lives as + // long as the built config, which resetAuth() discards on config change. + let cachedUserInfoUrl: string | null = provider.userInfoUrl + + async function fetchClaims(accessToken: string): Promise | null> { + if (!cachedUserInfoUrl && provider.discoveryUrl) { + const res = await fetch(provider.discoveryUrl) + if (!res.ok) return null + const doc = (await res.json()) as { userinfo_endpoint?: unknown } + if (typeof doc.userinfo_endpoint !== 'string') return null + cachedUserInfoUrl = doc.userinfo_endpoint + } + if (!cachedUserInfoUrl) return null + const res = await fetch(cachedUserInfoUrl, { + headers: { authorization: `Bearer ${accessToken}` }, + }) + if (!res.ok) return null + const claims = (await res.json()) as unknown + return claims !== null && typeof claims === 'object' + ? (claims as Record) + : null + } + + return async (tokens) => { + if (!tokens.accessToken) return null + + const claims = + mapping.source === 'accessTokenJwt' + ? decodeJwtPayload(tokens.accessToken) + : await fetchClaims(tokens.accessToken) + if (!claims) return null + + const id = resolveClaim(claims, mapping.idClaim ?? 'sub') + if (id === undefined || id === null || id === '') return null + + const name = resolveClaim(claims, mapping.nameClaim ?? 'name') + const emailClaim = resolveClaim(claims, mapping.emailClaim ?? 'email') + let email = typeof emailClaim === 'string' && emailClaim !== '' ? emailClaim : undefined + let emailVerified = resolveClaim(claims, 'email_verified') === true + if (!email && mapping.emailFallback) { + email = synthesizeEmail(mapping.emailFallback, String(id)) + emailVerified = true + } + + // A still-missing email is returned as-is so Better-Auth reports the + // accurate `email_is_missing` (not `user_info_is_missing`). + return { + ...claims, + id: String(id), + ...(typeof name === 'string' && name !== '' ? { name } : {}), + ...(email ? { email } : {}), + emailVerified, + } + } +} + export interface BuildGenericOAuthConfigsArgs { providers: IdentityProvider[] /** Fetches the decrypted credential blob for a provider's registrationId. */ @@ -137,6 +276,11 @@ export async function buildGenericOAuthConfigs({ // handleOAuthUserInfo before any user/session is created. Existing // users still link via accountLinking.trustedProviders. disableSignUp: provider.autoCreateUsers === false, + // Custom profile-claim resolution (opt-in via the profile_mapping + // column) for IdPs whose user info doesn't fit the OIDC defaults. + ...(provider.profileMapping + ? { getUserInfo: buildProfileMappingGetUserInfo(provider, provider.profileMapping) } + : {}), ...(mapProfileToUser ? { mapProfileToUser } : {}), ...(buildLoginHintParams ? { authorizationUrlParams: buildLoginHintParams } : {}), }) diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts index 6cdadcc0e..0e2fef73b 100644 --- a/apps/web/src/lib/server/db.ts +++ b/apps/web/src/lib/server/db.ts @@ -275,7 +275,10 @@ export { // Re-export schema types not covered by @quackback/db/types export type { ServiceMetadata } from '@quackback/db' -export type { IdentityProviderAttributeMapping } from '@quackback/db' +export type { + IdentityProviderAttributeMapping, + IdentityProviderProfileMapping, +} from '@quackback/db' // Re-export types (for client components that need types without side effects) export * from '@quackback/db/types' diff --git a/apps/web/src/lib/server/domains/settings/identity-providers.service.ts b/apps/web/src/lib/server/domains/settings/identity-providers.service.ts index a32b8877c..5aa151f7d 100644 --- a/apps/web/src/lib/server/domains/settings/identity-providers.service.ts +++ b/apps/web/src/lib/server/domains/settings/identity-providers.service.ts @@ -20,6 +20,7 @@ import { identityProvider, ssoVerifiedDomain, type IdentityProviderAttributeMapping, + type IdentityProviderProfileMapping, } from '@/lib/server/db' import type { IdentityProviderId } from '@quackback/ids' import { logger } from '@/lib/server/logger' @@ -75,6 +76,8 @@ export interface IdentityProvider { autoCreateUsers: boolean autoProvisionRole: 'admin' | 'member' | 'user' | null attributeMapping: IdentityProviderAttributeMapping | null + /** Custom profile-claim resolution; null = Better-Auth default user info. */ + profileMapping: IdentityProviderProfileMapping | null showButton: boolean /** ISO-8601 UTC; null until a redirect-affecting detail changes. */ detailsChangedAt: string | null @@ -111,6 +114,7 @@ export interface UpsertIdentityProviderInput { autoCreateUsers?: boolean autoProvisionRole?: 'admin' | 'member' | 'user' | null attributeMapping?: IdentityProviderAttributeMapping | null + profileMapping?: IdentityProviderProfileMapping | null showButton?: boolean } @@ -175,6 +179,7 @@ function rowToIdentityProvider( autoCreateUsers: row.autoCreateUsers, autoProvisionRole: row.autoProvisionRole, attributeMapping: row.attributeMapping ?? null, + profileMapping: row.profileMapping ?? null, showButton: row.showButton, detailsChangedAt: row.detailsChangedAt ? row.detailsChangedAt.toISOString() : null, lastSuccessfulTestAt: row.lastSuccessfulTestAt ? row.lastSuccessfulTestAt.toISOString() : null, @@ -368,6 +373,7 @@ export async function upsertIdentityProvider( if (input.autoCreateUsers !== undefined) patch.autoCreateUsers = input.autoCreateUsers if (input.autoProvisionRole !== undefined) patch.autoProvisionRole = input.autoProvisionRole if (input.attributeMapping !== undefined) patch.attributeMapping = input.attributeMapping + if (input.profileMapping !== undefined) patch.profileMapping = input.profileMapping if (input.showButton !== undefined) patch.showButton = input.showButton // Restamp the freshness baseline when a connection-affecting field @@ -413,6 +419,7 @@ export async function upsertIdentityProvider( autoCreateUsers: input.autoCreateUsers ?? true, autoProvisionRole: input.autoProvisionRole ?? null, attributeMapping: input.attributeMapping ?? null, + profileMapping: input.profileMapping ?? null, showButton: input.showButton ?? false, }) .returning() diff --git a/apps/web/src/lib/server/functions/sso.ts b/apps/web/src/lib/server/functions/sso.ts index ab7473f27..5d4a072c8 100644 --- a/apps/web/src/lib/server/functions/sso.ts +++ b/apps/web/src/lib/server/functions/sso.ts @@ -160,6 +160,23 @@ const attributeMappingSchema = z.object({ syncOnEverySignIn: z.boolean().optional(), }) +/** Profile-claim mapping mirror of `IdentityProviderProfileMapping`. */ +const profileMappingSchema = z.object({ + source: z.enum(['userinfo', 'accessTokenJwt']).optional(), + idClaim: z.string().max(256).optional(), + nameClaim: z.string().max(256).optional(), + emailClaim: z.string().max(256).optional(), + // Must contain exactly one @ so the synthesized value is a plausible + // address; {id} substitution is sanitized in build-oauth-configs. + emailFallback: z + .string() + .max(320) + .refine((t) => t.split('@').length === 2 && t.split('@')[1].length > 0, { + message: 'emailFallback must be an email template like {id}@sso.example.com', + }) + .optional(), +}) + /** * Identity-provider registrationIds are restricted to the generated `oidc_` * namespace plus the two legacy ids (`sso` / `custom-oidc`). This blocks @@ -195,6 +212,7 @@ const upsertIdentityProviderInput = z.object({ autoCreateUsers: z.boolean().optional(), autoProvisionRole: idpRole.nullable().optional(), attributeMapping: attributeMappingSchema.nullable().optional(), + profileMapping: profileMappingSchema.nullable().optional(), showButton: z.boolean().optional(), }) diff --git a/packages/db/drizzle/0126_identity_provider_profile_mapping.sql b/packages/db/drizzle/0126_identity_provider_profile_mapping.sql new file mode 100644 index 000000000..1256e5915 --- /dev/null +++ b/packages/db/drizzle/0126_identity_provider_profile_mapping.sql @@ -0,0 +1,7 @@ +-- Per-provider profile-claim mapping for IdPs whose user info doesn't fit the +-- OIDC defaults (sub/name/email from the id_token or userinfo endpoint). +-- Shape: { source?: 'userinfo' | 'accessTokenJwt', idClaim?, nameClaim?, +-- emailClaim?, emailFallback? } — see IdentityProviderProfileMapping in +-- packages/db/src/schema/auth.ts. Nullable: NULL keeps Better-Auth's default +-- user-info resolution, so existing providers are untouched. +ALTER TABLE "identity_provider" ADD COLUMN "profile_mapping" jsonb; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index ae54894ab..b94578be8 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -883,6 +883,13 @@ "when": 1783641600000, "tag": "0125_conversation_channel_drop_default", "breakpoints": true + }, + { + "idx": 126, + "version": "7", + "when": 1783728000000, + "tag": "0126_identity_provider_profile_mapping", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index 50f2c1d53..d3ac70d30 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -343,6 +343,43 @@ export type IdentityProviderAttributeMapping = { syncOnEverySignIn?: boolean } +/** + * Per-provider profile-claim mapping for IdPs whose user info doesn't fit + * the OIDC defaults (`sub`/`name`/`email` from the id_token or userinfo + * endpoint). When set, the auth builder attaches a custom `getUserInfo` + * to the provider's generic-oauth config. Declared here (like + * `IdentityProviderAttributeMapping`) so the jsonb column is typed + * without coupling the db package to the app layer. + * + * Example: EVE Online SSO returns no id_token and no email; its JWT + * access token carries `sub`/`name`, so + * `{ source: 'accessTokenJwt', emailFallback: '{id}@eve.example.com' }` + * makes sign-in work. + */ +export type IdentityProviderProfileMapping = { + /** + * Where profile claims come from. `userinfo` fetches the provider's + * userinfo endpoint (mapping applied on top); `accessTokenJwt` decodes + * the JWT access token's payload. Default `userinfo`. + */ + source?: 'userinfo' | 'accessTokenJwt' + /** Claim path for the account id. Default `sub`. */ + idClaim?: string + /** Claim path for the display name. Default `name`. */ + nameClaim?: string + /** Claim path for the email. Default `email`. */ + emailClaim?: string + /** + * Template used when the email claim is absent, e.g. + * `"{id}@sso.example.com"`. `{id}` is the resolved id-claim value + * sanitized to `[a-z0-9._-]`. Presence of this field is the opt-in for + * synthesized emails; such users are marked emailVerified (there is no + * real inbox to verify) and can never receive notifications or magic + * links. + */ + emailFallback?: string +} + /** * Identity provider — the single source of truth for an OIDC IdP. * @@ -390,6 +427,8 @@ export const identityProvider = pgTable( autoCreateUsers: boolean('auto_create_users').notNull().default(true), autoProvisionRole: text('auto_provision_role').$type<'admin' | 'member' | 'user'>(), attributeMapping: jsonb('attribute_mapping').$type(), + /** Non-null opts this provider into custom profile-claim resolution. */ + profileMapping: jsonb('profile_mapping').$type(), showButton: boolean('show_button').notNull().default(false), /** Bumped when redirect-affecting details change; freshness baseline. */ detailsChangedAt: timestamp('details_changed_at', { withTimezone: true }),