@@ -4,7 +4,7 @@ import { organization } from '@sim/db/schema'
44import { createLogger } from '@sim/logger'
55import { eq , sql } from 'drizzle-orm'
66import { MIN_IDLE_TIMEOUT_HOURS } from '@/lib/api/contracts/organization'
7- import { getMemberOrganizationId } from '@/lib/auth/security-policy'
7+ import { getMemberOrganizationId , invalidateMembershipCache } from '@/lib/auth/security-policy'
88
99const logger = createLogger ( 'SessionPolicy' )
1010
@@ -108,7 +108,7 @@ interface ClampableSession {
108108 activeOrganizationId ?: string | null
109109 impersonatedBy ?: string | null
110110 createdAt ?: Date | string | null
111- expiresAt ?: Date | null
111+ expiresAt ?: Date | string | null
112112}
113113
114114/**
@@ -122,18 +122,19 @@ interface ClampableSession {
122122 * sessions have no policy.
123123 */
124124export async function clampExpiryForSession ( session : ClampableSession ) : Promise < Date | undefined > {
125- if ( ! session . expiresAt || session . impersonatedBy ) {
126- return session . expiresAt ?? undefined
125+ // Better Auth context values can cross a serialization boundary — normalize
126+ // date fields in case they arrive as ISO strings rather than Dates.
127+ const expiresAt = session . expiresAt ? new Date ( session . expiresAt ) : undefined
128+ if ( ! expiresAt || session . impersonatedBy ) {
129+ return expiresAt
127130 }
128131 const organizationId =
129132 session . activeOrganizationId ?? ( await getMemberOrganizationId ( session . userId ) )
130- if ( ! organizationId ) return session . expiresAt
133+ if ( ! organizationId ) return expiresAt
131134
132135 const policy = await getSessionPolicy ( organizationId )
133- // Better Auth context values can cross a serialization boundary — normalize
134- // createdAt in case it arrives as an ISO string rather than a Date.
135136 const createdAt = session . createdAt ? new Date ( session . createdAt ) : new Date ( )
136- return clampSessionExpiry ( policy , createdAt , session . expiresAt )
137+ return clampSessionExpiry ( policy , createdAt , expiresAt )
137138}
138139
139140/**
@@ -150,6 +151,51 @@ export async function eagerClampOrgSessions(
150151 organizationId : string ,
151152 policy : ResolvedSessionPolicy
152153) : Promise < void > {
154+ const bounds = clampBoundsSql ( policy )
155+ if ( ! bounds ) return
156+
157+ await db . execute ( sql `
158+ UPDATE "session" SET expires_at = LEAST(${ bounds } )
159+ WHERE impersonated_by IS NULL
160+ AND user_id IN (
161+ SELECT user_id FROM member WHERE organization_id = ${ organizationId }
162+ )
163+ ` )
164+ }
165+
166+ /**
167+ * Applies the org's session policy to a user who just JOINED the org:
168+ * invalidates their cached membership (so the cookie-version and hook-clamp
169+ * fallbacks see the new org immediately) and clamps their pre-join sessions,
170+ * which otherwise keep their old expiry until the next sliding refresh.
171+ * Best-effort by design — a failure here must never fail the join; the
172+ * update-hook clamp self-heals within one refresh cycle.
173+ */
174+ export async function applySessionPolicyToNewMember (
175+ userId : string ,
176+ organizationId : string
177+ ) : Promise < void > {
178+ try {
179+ invalidateMembershipCache ( userId )
180+ const policy = await getSessionPolicy ( organizationId )
181+ const bounds = clampBoundsSql ( policy )
182+ if ( ! bounds ) return
183+
184+ await db . execute ( sql `
185+ UPDATE "session" SET expires_at = LEAST(${ bounds } )
186+ WHERE user_id = ${ userId } AND impersonated_by IS NULL
187+ ` )
188+ } catch ( error ) {
189+ logger . error ( 'Failed to apply session policy to new member; next refresh re-clamps' , {
190+ userId,
191+ organizationId,
192+ error,
193+ } )
194+ }
195+ }
196+
197+ /** SQL argument list for the LEAST() clamp, or null when the policy is empty. */
198+ function clampBoundsSql ( policy : ResolvedSessionPolicy ) {
153199 const bounds = [ sql `expires_at` ]
154200 if ( policy . maxSessionHours ) {
155201 const maxSecs = policy . maxSessionHours * 3600
@@ -159,13 +205,6 @@ export async function eagerClampOrgSessions(
159205 const idleSecs = Math . max ( policy . idleTimeoutHours , MIN_IDLE_TIMEOUT_HOURS ) * 3600
160206 bounds . push ( sql `now() + make_interval(secs => ${ idleSecs } )` )
161207 }
162- if ( bounds . length === 1 ) return
163-
164- await db . execute ( sql `
165- UPDATE "session" SET expires_at = LEAST(${ sql . join ( bounds , sql `, ` ) } )
166- WHERE impersonated_by IS NULL
167- AND user_id IN (
168- SELECT user_id FROM member WHERE organization_id = ${ organizationId }
169- )
170- ` )
208+ if ( bounds . length === 1 ) return null
209+ return sql . join ( bounds , sql `, ` )
171210}
0 commit comments