@@ -7,14 +7,17 @@ import {
77 user ,
88 workspace ,
99} from '@sim/db/schema'
10+ import { safeCompare } from '@sim/security/compare'
1011import { sha256Hex } from '@sim/security/hash'
12+ import { hmacSha256Hex } from '@sim/security/hmac'
1113import { getErrorMessage } from '@sim/utils/errors'
1214import { generateId } from '@sim/utils/id'
1315import { normalizeEmail , truncate } from '@sim/utils/string'
1416import { and , count , desc , eq , inArray , lt , or , sql } from 'drizzle-orm'
1517import { renderCredentialGroupInvitationEmail } from '@/components/emails/credential-groups/render'
1618import { getCredentialGroupInvitationSubject } from '@/components/emails/subjects'
1719import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
20+ import { env } from '@/lib/core/config/env'
1821import { getBaseUrl } from '@/lib/core/utils/urls'
1922import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability'
2023import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
@@ -134,26 +137,41 @@ export class CredentialGroupEnrollmentError extends Error {
134137}
135138
136139interface CredentialGroupEnrollmentCursor {
140+ v : 1
137141 id : string
138142 invitedAt : Date
139143}
140144
141145function encodeCredentialGroupEnrollmentCursor (
142- enrollment : Pick < EnrollmentRow , 'id' | 'invitedAt' >
146+ enrollment : Pick < EnrollmentRow , 'id' | 'invitedAt' > ,
147+ scope : string
143148) : string {
144- return Buffer . from (
145- JSON . stringify ( { id : enrollment . id , invitedAt : enrollment . invitedAt . toISOString ( ) } )
149+ const encoded = Buffer . from (
150+ JSON . stringify ( { v : 1 , id : enrollment . id , invitedAt : enrollment . invitedAt . toISOString ( ) } )
146151 ) . toString ( 'base64url' )
152+ return `${ encoded } .${ hmacSha256Hex ( `${ encoded } .${ scope } ` , env . BETTER_AUTH_SECRET ) } `
147153}
148154
149- function decodeCredentialGroupEnrollmentCursor ( cursor : string ) : CredentialGroupEnrollmentCursor {
155+ function decodeCredentialGroupEnrollmentCursor (
156+ cursor : string ,
157+ scope : string
158+ ) : CredentialGroupEnrollmentCursor {
150159 try {
151- const decoded : unknown = JSON . parse ( Buffer . from ( cursor , 'base64url' ) . toString ( 'utf8' ) )
160+ const [ encoded , signature , extra ] = cursor . split ( '.' )
161+ if ( ! encoded || ! signature || extra !== undefined ) {
162+ throw new Error ( 'Cursor token is malformed' )
163+ }
164+ const expectedSignature = hmacSha256Hex ( `${ encoded } .${ scope } ` , env . BETTER_AUTH_SECRET )
165+ if ( ! safeCompare ( signature , expectedSignature ) ) {
166+ throw new Error ( 'Cursor signature is invalid' )
167+ }
168+ const decoded : unknown = JSON . parse ( Buffer . from ( encoded , 'base64url' ) . toString ( 'utf8' ) )
152169 if ( ! decoded || typeof decoded !== 'object' || Array . isArray ( decoded ) ) {
153170 throw new Error ( 'Cursor payload must be an object' )
154171 }
155- const { id, invitedAt : invitedAtValue } = decoded as Record < string , unknown >
172+ const { v , id, invitedAt : invitedAtValue } = decoded as Record < string , unknown >
156173 if (
174+ v !== 1 ||
157175 typeof id !== 'string' ||
158176 ! id . trim ( ) ||
159177 id !== id . trim ( ) ||
@@ -166,12 +184,27 @@ function decodeCredentialGroupEnrollmentCursor(cursor: string): CredentialGroupE
166184 if ( Number . isNaN ( invitedAt . getTime ( ) ) || invitedAt . toISOString ( ) !== invitedAtValue ) {
167185 throw new Error ( 'Cursor timestamp is invalid' )
168186 }
169- return { id, invitedAt }
187+ return { v , id, invitedAt }
170188 } catch {
171189 throw new CredentialGroupEnrollmentError ( 'Enrollment cursor is invalid' , 400 )
172190 }
173191}
174192
193+ function credentialGroupEnrollmentCursorScope (
194+ workspaceId : string ,
195+ groupId : string ,
196+ filters : ListCredentialGroupEnrollmentFilters
197+ ) : string {
198+ return sha256Hex (
199+ JSON . stringify ( {
200+ workspaceId,
201+ groupId,
202+ email : filters . email || null ,
203+ statuses : [ ...new Set ( filters . statuses ?? [ ] ) ] . sort ( ) ,
204+ } )
205+ )
206+ }
207+
175208function hashInvitationToken ( token : string ) : string {
176209 return sha256Hex ( token )
177210}
@@ -482,7 +515,10 @@ export async function listCredentialGroupEnrollments(
482515 . filter ( ( option ) => option . status === 'active' )
483516 . map ( ( option ) => option . id )
484517
485- const cursorPosition = cursor ? decodeCredentialGroupEnrollmentCursor ( cursor ) : undefined
518+ const cursorScope = credentialGroupEnrollmentCursorScope ( workspaceId , groupId , filters )
519+ const cursorPosition = cursor
520+ ? decodeCredentialGroupEnrollmentCursor ( cursor , cursorScope )
521+ : undefined
486522
487523 const rows = await db
488524 . select ( { enrollment : credentialGroupEnrollment } )
@@ -564,7 +600,7 @@ export async function listCredentialGroupEnrollments(
564600 connections : connectionsByEnrollment . get ( enrollment . id ) ?? [ ] ,
565601 } ) ) ,
566602 nextCursor : nextCursorEnrollment
567- ? encodeCredentialGroupEnrollmentCursor ( nextCursorEnrollment )
603+ ? encodeCredentialGroupEnrollmentCursor ( nextCursorEnrollment , cursorScope )
568604 : null ,
569605 }
570606}
0 commit comments