Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0cefe01
Concurrent Login Feature
chetankoli-ll Jul 6, 2026
e0e8d43
class serializer interceptor added for impact of Exclude and Expose
chetankoli-ll Jul 6, 2026
5dad052
chatter message endpoint made public
oswald-logicLoop Jul 9, 2026
a731fa6
add security against chatter
saibazkhan-maker Jul 9, 2026
56f72b4
fix to allow linking a userKey belonging to the parent model while s…
oswald-logicLoop Jul 9, 2026
a5e40a8
updatedAt value is updating now
chetankoli-ll Jul 9, 2026
2e3d38d
add validation on get chatter message
saibazkhan-maker Jul 9, 2026
75260f7
solidx session invalid changes
chetankoli-ll Jul 10, 2026
4690d1f
some fixes
chetankoli-ll Jul 10, 2026
86cc4de
Merge pull request #89 from SolidXAI/solidx-concurrent-login
oswald-logicLoop Jul 13, 2026
f9953ef
Merge pull request #91 from SolidXAI/class-serializer-interceptor-added
oswald-logicLoop Jul 13, 2026
9023ec7
Merge pull request #96 from SolidXAI/solidx-updatedAt-fix
oswald-logicLoop Jul 13, 2026
a0f5956
add private media support
saibazkhan-maker Jul 13, 2026
41c4fc5
Merge pull request #98 from SolidXAI/add-private-media-provider
oswald-logicLoop Jul 13, 2026
844876c
rename function name
saibazkhan-maker Jul 13, 2026
d3e6060
Merge pull request #100 from SolidXAI/add-private-media-provider
oswald-logicLoop Jul 13, 2026
ee4573f
Merge pull request #99 from SolidXAI/security-against-chatter
oswald-logicLoop Jul 13, 2026
5c86cd6
Revert "rename function name"
oswald-logicLoop Jul 13, 2026
4bf574d
Revert "add private media support"
oswald-logicLoop Jul 13, 2026
95cb70b
0.1.12-beta.0
oswald-logicLoop Jul 13, 2026
297bbfd
handle technical error messages due to missing resource in static res…
oswald-logicLoop Jul 13, 2026
002a610
0.1.12-beta.1
oswald-logicLoop Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@solidxai/core",
"version": "0.1.11",
"version": "0.1.12-beta.1",
"description": "This module is a NestJS module containing all the required core providers required by a Solid application",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
1 change: 1 addition & 0 deletions src/constants/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const ERROR_MESSAGES = {

// general errors
FORBIDDEN: 'Forbidden',
RESOURCE_NOT_FOUND: 'The requested resource was not found.',


// database errors
Expand Down
14 changes: 7 additions & 7 deletions src/controllers/chatter-message.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ enum ShowSoftDeleted {
export class ChatterMessageController {
constructor(private readonly service: ChatterMessageService) {}

@ApiBearerAuth("jwt")
@Post()
@UseInterceptors(AnyFilesInterceptor())
create(@Body() createDto: CreateChatterMessageDto, @UploadedFiles() files: Array<Express.Multer.File>) {
return this.service.create(createDto, files);
}
// @ApiBearerAuth("jwt")
// @Post()
// @UseInterceptors(AnyFilesInterceptor())
// create(@Body() createDto: CreateChatterMessageDto, @UploadedFiles() files: Array<Express.Multer.File>) {
// return this.service.create(createDto, files);
// }

// @ApiBearerAuth("jwt")
// @Post('/bulk')
Expand Down Expand Up @@ -61,7 +61,7 @@ export class ChatterMessageController {
// return this.service.recover(id);
// }

@Public()
@ApiBearerAuth("jwt")
@Get('/getChatterMessages/:entityId/:entityName')
@ApiQuery({ name: 'showSoftDeleted', required: false, enum: ShowSoftDeleted })
@ApiQuery({ name: 'limit', required: false, type: Number })
Expand Down
46 changes: 43 additions & 3 deletions src/guards/access-token.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,26 @@ import type { SolidCoreSetting } from "src/services/settings/default-settings-pr
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { ERROR_MESSAGES } from "src/constants/error-messages";
import { ActiveUserData } from '../interfaces/active-user-data.interface';
import { REQUEST_USER_KEY } from "../constants";
import { PermissionMetadataService } from '../services/permission-metadata.service';
import { ClsService } from 'nestjs-cls';
import { ActiveSessionStorageService } from "../services/active-session-storage.service";
import { SettingService } from '../services/setting.service';
import { createHash } from "crypto";

@Injectable()
export class AccessTokenGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly permissionsService: PermissionMetadataService,
private readonly activeSessionStorage: ActiveSessionStorageService,
private readonly settingService: SettingService,
private readonly cls: ClsService
) { }
Expand All @@ -44,6 +47,9 @@ export class AccessTokenGuard implements CanActivate {
jwtConfiguration
);

// Prevent Concurrent Login Feature
await this.validateConcurrentLoginSession(payload, token);

// Load permissions given the user.
const permissions = await this.permissionsService.findAllUsingRoles(payload.roles);
payload.permissions = permissions.map((permission) => permission.name);
Expand All @@ -52,8 +58,10 @@ export class AccessTokenGuard implements CanActivate {
this.cls.set(REQUEST_USER_KEY, payload);
// console.log(`About to set payload in the request user key:`);
// console.log(payload);
} catch {
throw new UnauthorizedException();
} catch (err) {
throw err instanceof UnauthorizedException
? err
: new UnauthorizedException();
}
return true;
}
Expand All @@ -64,4 +72,36 @@ export class AccessTokenGuard implements CanActivate {
const [_, token] = request.headers.authorization?.split(' ') ?? [];
return token;
}

private resolveSessionId(payload: ActiveUserData, token: string): string {
if (payload.sessionId) {
return payload.sessionId;
}

// Legacy tokens (issued before preventConcurrentLogins was enabled)
// have no sessionId claim, so derive a stable fallback from the token.
return createHash('sha256').update(token).digest('hex');
}

private async validateConcurrentLoginSession(payload: ActiveUserData, token: string,): Promise<void> {
if (!this.settingService.getConfigValue<SolidCoreSetting>("preventConcurrentLogins",)) {
return;
}

const activeSessionId = await this.activeSessionStorage.getActiveSession(payload.sub);
const currentSessionId = this.resolveSessionId(payload, token);

if (!activeSessionId) {
// No active session recorded yet for this user — this is the first
// request we've seen since the feature was enabled (or storage was
// cleared). Adopt this request's session as the active one so this
// browser stays logged in going forward.
await this.activeSessionStorage.setActiveSession(payload.sub, currentSessionId,);
return;
}

if (currentSessionId !== activeSessionId) {
throw new UnauthorizedException(ERROR_MESSAGES.SESSION_INVALID);
}
}
}
23 changes: 22 additions & 1 deletion src/guards/authentication.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ export class AuthenticationGuard implements CanActivate {
private readonly cls: ClsService,
) { }

private isGenericUnauthorizedError(error: unknown): boolean {
if (!(error instanceof UnauthorizedException)) {
return false;
}

const response = error.getResponse();
const message =
typeof response === 'string'
? response
: Array.isArray((response as any)?.message)
? (response as any).message[0]
: (response as any)?.message;

return !message || message === 'Unauthorized';
}

async canActivate(context: ExecutionContext): Promise<boolean> {
// If method marked as public, then we return with true, else go ahead and apply the access token guard.
const contextLog = context.getHandler();
Expand Down Expand Up @@ -72,7 +88,12 @@ export class AuthenticationGuard implements CanActivate {
const canActivate = await Promise.resolve(
instance.canActivate(context),
).catch((err) => {
error = err;
if (
this.isGenericUnauthorizedError(error) ||
!this.isGenericUnauthorizedError(err)
) {
error = err;
}
});

if (canActivate) {
Expand Down
12 changes: 9 additions & 3 deletions src/helpers/bootstrap.helper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
import { NestFactory, Reflector } from '@nestjs/core';
import { WsAdapter } from '@nestjs/platform-ws';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { NextFunction, Request, Response } from 'express';
Expand Down Expand Up @@ -169,7 +169,13 @@ export async function bootstrapSolidApp(
}

// Global interceptor
app.useGlobalInterceptors(new WrapResponseInterceptor());
app.useGlobalInterceptors(
new ClassSerializerInterceptor(app.get(Reflector), {
enableImplicitConversion: true, // optional but handy
// groups: ['client'], // if you use groups
}),
new WrapResponseInterceptor()
);

// CORS
app.enableCors(buildDefaultCorsOptions());
Expand Down
30 changes: 29 additions & 1 deletion src/helpers/solid-core-error-codes-provider.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// src/common/errors/providers/solidcore-error-code.provider.ts
import { Injectable } from '@nestjs/common';
import { ERROR_MESSAGES } from 'src/constants/error-messages';
import { ErrorCodeProvider } from 'src/decorators/error-codes-provider.decorator';
import { ErrorMeta, ErrorRule, IErrorCodeProvider } from 'src/interfaces';

Expand All @@ -13,6 +14,24 @@ export class SolidCoreErrorCodesProvider implements IErrorCodeProvider {

rules(): ReadonlyArray<ErrorRule> {
return [
{
code: 'solidx-session-invalid',
priority: 110,
match: (txt) => txt.includes(ERROR_MESSAGES.SESSION_INVALID.toLowerCase()),
meta: {
message: ERROR_MESSAGES.SESSION_INVALID,
httpStatus: 401,
},
},
{
code: 'solidx-session-expired',
priority: 110,
match: (txt) => txt.includes(ERROR_MESSAGES.SESSION_EXPIRED.toLowerCase()),
meta: {
message: ERROR_MESSAGES.SESSION_EXPIRED,
httpStatus: 401,
},
},
{
code: 'solidx-mcp-server-unavailable',
priority: 100,
Expand All @@ -24,6 +43,15 @@ export class SolidCoreErrorCodesProvider implements IErrorCodeProvider {
httpStatus: 503,
},
},
{
code: 'solidx-resource-not-found',
priority: 95,
match: (txt) => txt.includes('enoent') && txt.includes('no such file or directory'),
meta: {
message: ERROR_MESSAGES.RESOURCE_NOT_FOUND,
httpStatus: 404,
},
},
{
code: 'solidx-db-duplicate-key',
priority: 90,
Expand Down Expand Up @@ -60,4 +88,4 @@ export class SolidCoreErrorCodesProvider implements IErrorCodeProvider {
const rule = this.rules().find((r) => r.code === code);
return rule?.meta;
}
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './commands/run-tests.command'
export * from './commands/test.command'

export * from './config/cache.options'
export * from './services/active-session-storage.service'

export * from './decorators/active-user.decorator'
export * from './decorators/solid-request-context.decorator'
Expand Down
6 changes: 6 additions & 0 deletions src/interfaces/active-user-data.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export interface ActiveUserData {
*/
email: string;

/**
* Logical login session identifier used to invalidate older sessions when
* concurrent logins are disabled.
*/
sessionId?: string;

/**
* The subject's (user) roles.
* These are part of the JWT token, we simply decode them.
Expand Down
25 changes: 23 additions & 2 deletions src/seeders/module-metadata-seeder.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1536,8 +1536,9 @@ export class ModuleMetadataSeederService {
const { fields: fieldsMetadata, ...modelMetaDataWithoutFields } = modelMetadata;

// Load and set the parent model if it exists.
let parentModel: ModelMetadata | null = null;
if (modelMetadata.isChild && modelMetadata.parentModelUserKey) {
const parentModel = await this.getModelByUserKeyCached(modelMetadata.parentModelUserKey, {
parentModel = await this.getModelByUserKeyCached(modelMetadata.parentModelUserKey, {
moduleName: moduleMetadata.name,
component: 'module-model-fields',
details: `parentModel=${modelMetadata.parentModelUserKey}`,
Expand Down Expand Up @@ -1605,7 +1606,27 @@ export class ModuleMetadataSeederService {
}
}

// Now that we have created fields & model update the model to stamp the userKeyField.
// If userKeyField wasn't found among this model's own fields (e.g. STI "extension" models
// whose userKeyField is a column inherited from the parent entity), look it up on the
// parent model by the same name.
if (!userKeyField && modelMetadata.isChild && parentModel && userKeyFieldName) {
const inheritedUserKeyField = await this.timeOperation('field-find-inherited-user-key', () => fieldMetadataRepo.findOne({
where: {
model: { id: parentModel.id },
name: userKeyFieldName,
},
}), {
moduleName: moduleMetadata.name,
component: 'module-model-fields',
serviceCall: 'fieldMetadataRepo.findOne',
details: `model=${modelMetadata.singularName} parentModel=${modelMetadata.parentModelUserKey} field=${userKeyFieldName}`,
});
if (inheritedUserKeyField) {
userKeyField = inheritedUserKeyField;
}
}

// Now that we have created fields & model update the model to stamp the userKeyField.
if (userKeyField) {
modelMetaDataWithoutFields['userKeyField'] = userKeyField;
await this.timeOperation('model-user-key-field-upsert', () => this.modelMetadataService.upsert(modelMetaDataWithoutFields), {
Expand Down
1 change: 0 additions & 1 deletion src/seeders/seed-data/solid-core-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -5304,7 +5304,6 @@
"SavedFiltersController.update",
"SavedFiltersController.insertMany",
"SavedFiltersController.create",
"ChatterMessageController.create",
"ChatterMessageController.getChatterMessages",
"ChatterMessageController.postMessage",
"ChatterMessageController.findMany",
Expand Down
24 changes: 24 additions & 0 deletions src/services/active-session-storage.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable } from "@nestjs/common";
import { Cache } from "cache-manager";

@Injectable()
export class ActiveSessionStorageService {
constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {}

async setActiveSession(userId: number, sessionId: string): Promise<void> {
await this.cacheManager.set(this.getKey(userId), sessionId);
}

async getActiveSession(userId: number): Promise<string | undefined> {
return this.cacheManager.get(this.getKey(userId));
}

async clearActiveSession(userId: number): Promise<void> {
await this.cacheManager.del(this.getKey(userId));
}

private getKey(userId: number): string {
return `active-session-${userId}`;
}
}
Loading