@@ -85,12 +85,24 @@ export const enum AccessLevel {
8585}
8686
8787/** Entity types used in the permissions table. */
88- export const enum EntityType {
88+ export enum EntityType {
8989 Score = "score" ,
9090 Folder = "folder" ,
9191 Feature = "feature" ,
9292}
9393
94+ const entityTypeValues = Object . values ( EntityType ) as string [ ] ;
95+
96+ /**
97+ * Type guard: checks whether a string is a valid {@link EntityType} value.
98+ *
99+ * @param value The string to check.
100+ * @returns True if the value is a valid EntityType.
101+ */
102+ export const isValidEntityType = ( value : string ) : value is EntityType => {
103+ return entityTypeValues . includes ( value ) ;
104+ } ;
105+
94106export enum LoginAuditEvent {
95107 Login = "login" ,
96108 GroupLogin = "group_login" ,
@@ -123,7 +135,15 @@ export class Auth {
123135 private static scryptKeyLen = 64 ;
124136 private static scryptOptions = { N : 16384 , r : 8 , p : 1 } ;
125137
126- private static readonly jwtSecret : string ;
138+ private static jwtSecret : string = ( ( ) => {
139+ // eslint-disable-next-line no-restricted-syntax
140+ const secret = process . env . JWT_SECRET ;
141+ if ( ! secret ) {
142+ throw new Error ( "JWT_SECRET environment variable is required." ) ;
143+ }
144+
145+ return secret ;
146+ } ) ( ) ;
127147
128148 private currentAdapter : IDatabaseAdapter ;
129149
@@ -292,15 +312,15 @@ export class Auth {
292312 *
293313 * @returns True if the user has the required access.
294314 */
295- public async checkPermission ( user : ITokenPayload | undefined , entityType : string ,
315+ public async checkPermission ( user : ITokenPayload | undefined , entityType : EntityType ,
296316 entityId : number | null , requiredLevel : AccessLevel , ) : Promise < boolean > {
297317 // Admin users always have full access.
298318 if ( user && await this . isUserInAdminGroup ( user . userId ) ) {
299319 return true ;
300320 }
301321
302322 // Features: currently only admins get access (features will be reworked separately).
303- if ( ( entityType as EntityType ) === EntityType . Feature ) {
323+ if ( entityType === EntityType . Feature ) {
304324 return false ;
305325 }
306326
@@ -369,7 +389,7 @@ export class Auth {
369389 *
370390 * @returns A summary of the user's access.
371391 */
372- public async getPermissionSummary ( user : ITokenPayload | undefined , entityType : string ,
392+ public async getPermissionSummary ( user : ITokenPayload | undefined , entityType : EntityType ,
373393 entityId : number ) : Promise < IPermissionSummary > {
374394 const isAdmin = user ? await this . isUserInAdminGroup ( user . userId ) : false ;
375395
@@ -439,7 +459,7 @@ export class Auth {
439459 * @param entityId The entity id.
440460 * @param ownerId The new owner id, or null to remove the explicit owner (inherit).
441461 */
442- public async setOwner ( entityType : string , entityId : number ,
462+ public async setOwner ( entityType : EntityType , entityId : number ,
443463 ownerId : number | null ) : Promise < void > {
444464 if ( ownerId === null ) {
445465 // Remove the explicit row — inheritance takes over.
@@ -464,7 +484,7 @@ export class Auth {
464484 * @param groupId The group id.
465485 * @param writable Whether the group has write access.
466486 */
467- public async addEntityGroup ( entityType : string , entityId : number ,
487+ public async addEntityGroup ( entityType : EntityType , entityId : number ,
468488 groupId : number , writable : boolean ) : Promise < void > {
469489 await this . adapter . execute (
470490 `INSERT INTO entity_groups (entity_type, entity_id, group_id, writable) ` +
@@ -480,7 +500,7 @@ export class Auth {
480500 * @param entityId The entity id.
481501 * @param groupId The group id.
482502 */
483- public async removeEntityGroup ( entityType : string , entityId : number ,
503+ public async removeEntityGroup ( entityType : EntityType , entityId : number ,
484504 groupId : number ) : Promise < void > {
485505 await this . adapter . execute (
486506 "DELETE FROM entity_groups WHERE entity_type = ? AND entity_id = ? AND group_id = ?" ,
@@ -494,7 +514,7 @@ export class Auth {
494514 * @param entityType The entity type.
495515 * @param entityId The entity id.
496516 */
497- public async getExplicitEntityGroups ( entityType : string ,
517+ public async getExplicitEntityGroups ( entityType : EntityType ,
498518 entityId : number ) : Promise < IEntityGroupEntry [ ] > {
499519 const rows = await this . adapter . query < { group_id : number ; writable : number ; } > (
500520 "SELECT group_id, writable FROM entity_groups WHERE entity_type = ? AND entity_id = ?" ,
@@ -514,7 +534,7 @@ export class Auth {
514534 *
515535 * @returns The owner id, or null.
516536 */
517- public async getExplicitOwner ( entityType : string ,
537+ public async getExplicitOwner ( entityType : EntityType ,
518538 entityId : number ) : Promise < number | null > {
519539 const rows = await this . adapter . query < { owner_id : number | null ; } > (
520540 "SELECT owner_id FROM permissions WHERE entity_type = ? AND entity_id = ?" ,
@@ -614,9 +634,18 @@ export class Auth {
614634 *
615635 * @param entityType The type of entity.
616636 * @param entityId The entity id.
637+ * @param visited Set of already-visited entity keys to detect cycles.
617638 * @returns The resolved owner id, or null.
618639 */
619- private async resolveOwner ( entityType : string , entityId : number ) : Promise < number | null > {
640+ private async resolveOwner ( entityType : EntityType , entityId : number ,
641+ visited = new Set < string > ( ) , ) : Promise < number | null > {
642+ const key = `${ entityType } :${ entityId } ` ;
643+ if ( visited . has ( key ) ) {
644+ return null ;
645+ }
646+
647+ visited . add ( key ) ;
648+
620649 // Check for an explicit owner on this entity.
621650 const rows = await this . adapter . query < { owner_id : number | null ; } > (
622651 "SELECT owner_id FROM permissions WHERE entity_type = ? AND entity_id = ?" ,
@@ -628,26 +657,26 @@ export class Auth {
628657 }
629658
630659 // For scores, walk up to the parent folder.
631- if ( ( entityType as EntityType ) === EntityType . Score ) {
660+ if ( entityType === EntityType . Score ) {
632661 const scoreRows = await this . adapter . query < { folderid : number | null ; } > (
633662 "SELECT folderid FROM scores WHERE id = ?" ,
634663 [ entityId ] ,
635664 ) ;
636665
637666 if ( scoreRows [ 0 ] ?. folderid !== null ) {
638- return this . resolveOwner ( EntityType . Folder , scoreRows [ 0 ] . folderid ) ;
667+ return this . resolveOwner ( EntityType . Folder , scoreRows [ 0 ] . folderid , visited ) ;
639668 }
640669 }
641670
642671 // For folders, walk up to the parent folder.
643- if ( ( entityType as EntityType ) === EntityType . Folder ) {
672+ if ( entityType === EntityType . Folder ) {
644673 const folderRows = await this . adapter . query < { parentid : number | null ; } > (
645674 "SELECT parentid FROM folders WHERE id = ?" ,
646675 [ entityId ] ,
647676 ) ;
648677
649678 if ( folderRows [ 0 ] ?. parentid !== null ) {
650- return this . resolveOwner ( EntityType . Folder , folderRows [ 0 ] . parentid ) ;
679+ return this . resolveOwner ( EntityType . Folder , folderRows [ 0 ] . parentid , visited ) ;
651680 }
652681 }
653682
@@ -662,10 +691,18 @@ export class Auth {
662691 * @param entityType The type of entity.
663692 * @param entityId The entity id.
664693 * @param collected Accumulator map (groupId → writable). Pass a new Map() on first call.
694+ * @param visited Set of already-visited entity keys to detect cycles.
665695 * @returns A map of groupId → writable.
666696 */
667- private async collectGroupEntries ( entityType : string , entityId : number ,
668- collected : Map < number , boolean > , ) : Promise < Map < number , boolean > > {
697+ private async collectGroupEntries ( entityType : EntityType , entityId : number ,
698+ collected : Map < number , boolean > , visited = new Set < string > ( ) , ) : Promise < Map < number , boolean > > {
699+ const key = `${ entityType } :${ entityId } ` ;
700+ if ( visited . has ( key ) ) {
701+ return collected ;
702+ }
703+
704+ visited . add ( key ) ;
705+
669706 // Collect explicit group assignments for this entity.
670707 const rows = await this . adapter . query < { group_id : number ; writable : number ; } > (
671708 "SELECT group_id, writable FROM entity_groups WHERE entity_type = ? AND entity_id = ?" ,
@@ -682,25 +719,25 @@ export class Auth {
682719 }
683720
684721 // Walk up to the parent.
685- if ( ( entityType as EntityType ) === EntityType . Score ) {
722+ if ( entityType === EntityType . Score ) {
686723 const scoreRows = await this . adapter . query < { folderid : number | null ; } > (
687724 "SELECT folderid FROM scores WHERE id = ?" ,
688725 [ entityId ] ,
689726 ) ;
690727
691728 if ( scoreRows [ 0 ] ?. folderid !== null ) {
692- return this . collectGroupEntries ( EntityType . Folder , scoreRows [ 0 ] . folderid , collected ) ;
729+ return this . collectGroupEntries ( EntityType . Folder , scoreRows [ 0 ] . folderid , collected , visited ) ;
693730 }
694731 }
695732
696- if ( ( entityType as EntityType ) === EntityType . Folder ) {
733+ if ( entityType === EntityType . Folder ) {
697734 const folderRows = await this . adapter . query < { parentid : number | null ; } > (
698735 "SELECT parentid FROM folders WHERE id = ?" ,
699736 [ entityId ] ,
700737 ) ;
701738
702739 if ( folderRows [ 0 ] ?. parentid !== null ) {
703- return this . collectGroupEntries ( EntityType . Folder , folderRows [ 0 ] . parentid , collected ) ;
740+ return this . collectGroupEntries ( EntityType . Folder , folderRows [ 0 ] . parentid , collected , visited ) ;
704741 }
705742 }
706743
@@ -715,7 +752,7 @@ export class Auth {
715752 * @param entityId The entity id.
716753 * @returns The resolved permission state.
717754 */
718- private async resolvePermission ( entityType : string , entityId : number ) : Promise < IResolvedPermission > {
755+ private async resolvePermission ( entityType : EntityType , entityId : number ) : Promise < IResolvedPermission > {
719756 const [ ownerId , groupEntries ] = await Promise . all ( [
720757 this . resolveOwner ( entityType , entityId ) ,
721758 this . collectGroupEntries ( entityType , entityId , new Map ( ) ) ,
@@ -743,12 +780,4 @@ export class Auth {
743780 return rows [ 0 ] ?. id ;
744781 } ;
745782
746- static {
747- // @ts -expect-error, the field is readonly.
748- // eslint-disable-next-line no-restricted-syntax
749- this . jwtSecret = process . env . JWT_SECRET ;
750- if ( ! this . jwtSecret ) {
751- throw new Error ( "JWT_SECRET environment variable is required." ) ;
752- }
753- }
754783}
0 commit comments