Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b4cb84a
PR
Junjiequan Nov 11, 2025
1962f38
init-2
Junjiequan Nov 17, 2025
105e4b3
add history plugin for runtime service and reuse computeDeltaWithOrig…
Junjiequan Nov 18, 2025
e7e05c5
revert frontend config
Junjiequan Nov 20, 2025
7d24f30
fix unit test
Junjiequan Nov 21, 2025
0e8137c
add apiokReponse
Junjiequan Dec 3, 2025
59ff37b
Merge branch 'master' into SWAP-5086-scicat-be-poc-store-frontend-con…
Junjiequan Jan 8, 2026
cbf66e7
move run-time config into config folder
Junjiequan Jan 8, 2026
b8bd995
Restart application overwrittes previous config record with config file
Junjiequan Jan 8, 2026
07d9b90
remove description and replace _id with cid
Junjiequan Jan 8, 2026
d14a931
add missing decorators
Junjiequan Jan 8, 2026
00c0cd1
Replace previous admin/config fetch with RuntimeConfigService.getConfig
Junjiequan Jan 9, 2026
3e7b2e3
fix admin service unit test
Junjiequan Jan 9, 2026
07f51d6
update doc
Junjiequan Jan 12, 2026
5a167a1
add un-immutable createdBy
Junjiequan Jan 12, 2026
843c682
Merge branch 'master' into SWAP-5086-scicat-be-poc-store-frontend-con…
Junjiequan Jan 12, 2026
9b8076e
Merge branch 'master' into SWAP-5086-scicat-be-poc-store-frontend-con…
Junjiequan Jan 19, 2026
9f42e3a
Merge branch 'master' into SWAP-5086-scicat-be-poc-store-frontend-con…
totopoloco Jan 21, 2026
d52464f
fixes based on comment
Junjiequan Jan 21, 2026
bbcd9b8
unit and api tests included
Junjiequan Jan 21, 2026
2a0869b
Merge branch 'master' into SWAP-5086-scicat-be-poc-store-frontend-con…
Junjiequan Jan 26, 2026
d46b325
udpate runtime-config controller tag
Junjiequan Jan 26, 2026
09df060
Merge branch 'master' into SWAP-5086-scicat-be-poc-store-frontend-con…
Junjiequan Jan 29, 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
54 changes: 54 additions & 0 deletions docs/developer-guide/runtime-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
title: Runtime Configuration System Overview
audience: Technical
created_by: Junjie Quan
created_on: 2026-01-08
---

# Runtime Configuration: Technical Documentation

## Overview

SciCat frontend supports runtime-editable configuration stored in the backend and fetched dynamically at application startup.

Configuration is identified by a configuration ID (cid) and accessed via the runtime-config API.

## Predefined Configuration IDs

```json
CONFIG_SYNC_TO_DB_LIST="frontendConfig, frontendTheme"
```

- When provided, it is parsed as a comma-separated list of configuration IDs.
- When not provided, the system always includes the following defaults: - frontendConfig - frontendTheme
These default configuration IDs are always synchronized to ensure frontend functionality.

### Runtime Config API

- `GET /api/v3/runtime-config/:id`
Retrieves the current runtime configuration by `cid`. Response includes: `cid`, `data`, `updatedBy`

- `PUT /api/v3/runtime-config/:id`
Updates the runtime configuration and persists changes to the database. This endpoint accepts only the data object in the request body and `updatedBy` is automatically set from the user name.

## Backend Synchronization Flow

On backend startup:

1. `CONFIG_SYNC_TO_DB_LIST` is read from environment variables.
2. For each configuration ID:
- If no record exists in the database, a new record is created from the corresponding config file.
- If a record already exists, it is overwritten with values from the config file.
3. After initialization, all changes are managed via the `runtime-config` API.

## Extensibility

The system is designed to support additional runtime configurations in the future.

For any new configuration to be editable via the UI:

1. The configuration ID must be included in CONFIG_SYNC_TO_DB_LIST
2. A corresponding JSONForms Schema & UI Schema must be provided
3. The schema files must be created in the frontend

Without schema support, configuration values may exist in the database but will not be editable via the UI.
2 changes: 2 additions & 0 deletions src/admin/admin.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { AdminService } from "./admin.service";
import { AdminController } from "./admin.controller";
import { RuntimeConfigModule } from "src/config/runtime-config/runtime-config.module";

@Module({
imports: [RuntimeConfigModule],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
Expand Down
23 changes: 10 additions & 13 deletions src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import { AdminService } from "./admin.service";
import { RuntimeConfigService } from "src/config/runtime-config/runtime-config.service";

const mockConfig: Record<string, unknown> = {
accessTokenPrefix: "Bearer ",
Expand Down Expand Up @@ -51,6 +51,7 @@ const mockConfig: Record<string, unknown> = {
searchSamples: true,
sftpHost: "login.esss.dk",
sourceFolder: "/data/ess",
maxFileUploadSizeInMb: "12mb",
maxDirectDownloadSize: 5000000000,
maxFileSizeWarning:
"Some files are above <maxDirectDownloadSize> and cannot be downloaded directly. These file can be downloaded via sftp host: <sftpHost> in directory: <sourceFolder>",
Expand Down Expand Up @@ -89,12 +90,11 @@ const mockTheme: Record<string, unknown> = {

describe("AdminService", () => {
let service: AdminService;
const mockConfigService = {
get: jest.fn((propertyPath: string) => {
const mockRuntimeConfigService = {
getConfig: jest.fn((propertyPath: string) => {
const config = {
maxFileUploadSizeInMb: "12mb",
frontendConfig: mockConfig,
frontendTheme: mockTheme,
frontendConfig: { cid: "frontendConfig", data: mockConfig },
frontendTheme: { cid: "frontendTheme", data: mockTheme },
} as Record<string, unknown>;

return config[propertyPath];
Expand All @@ -106,8 +106,8 @@ describe("AdminService", () => {
providers: [
AdminService,
{
provide: ConfigService,
useValue: mockConfigService,
provide: RuntimeConfigService,
useValue: mockRuntimeConfigService,
},
],
}).compile();
Expand All @@ -120,13 +120,10 @@ describe("AdminService", () => {
});

describe("getConfig", () => {
it("should return modified config", async () => {
it("should return frontend config", async () => {
const result = await service.getConfig();

expect(result).toEqual({
...mockConfig,
maxFileUploadSizeInMb: "12mb",
});
expect(result).toEqual(mockConfig);
});
});

Expand Down
29 changes: 6 additions & 23 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,18 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { RuntimeConfigService } from "src/config/runtime-config/runtime-config.service";

@Injectable()
export class AdminService {
constructor(private configService: ConfigService) {}
constructor(private runtimeConfigService: RuntimeConfigService) {}

async getConfig(): Promise<Record<string, unknown> | null> {
const modifiedConfig = this.applyBackendConfigAdjustments();
const config = await this.runtimeConfigService.getConfig("frontendConfig");

return modifiedConfig;
return config?.data || null;
}

async getTheme(): Promise<Record<string, unknown> | null> {
const theme =
this.configService.get<Record<string, unknown>>("frontendTheme") || null;
return theme;
}

// NOTE: Adjusts backend config values for frontend use (e.g., file upload limits).
// Add future backend-dependent adjustments here as needed.
private applyBackendConfigAdjustments(): Record<string, unknown> | null {
const config =
this.configService.get<Record<string, unknown>>("frontendConfig") || null;
if (!config) {
return null;
}
const postEncodedMaxFileUploadSize =
this.configService.get<string>("maxFileUploadSizeInMb") || "16mb";
return {
...config,
maxFileUploadSizeInMb: postEncodedMaxFileUploadSize,
};
const theme = await this.runtimeConfigService.getConfig("frontendTheme");
return theme?.data || null;
}
}
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
} from "./common/schemas/generic-history.schema";
import { HistoryModule } from "./history/history.module";
import { MaskSensitiveDataInterceptorModule } from "./common/interceptors/mask-sensitive-data.interceptor";
import { RuntimeConfigModule } from "./config/runtime-config/runtime-config.module";

@Module({
imports: [
Expand All @@ -50,6 +51,7 @@ import { MaskSensitiveDataInterceptorModule } from "./common/interceptors/mask-s
isGlobal: true,
cache: true,
}),
RuntimeConfigModule,
AuthModule,
CaslModule,
AttachmentsModule,
Expand Down
1 change: 0 additions & 1 deletion src/attachments/attachments.v4.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
}),
};

const mockAttachmentsV4Service = {

Check warning on line 34 in src/attachments/attachments.v4.controller.spec.ts

View workflow job for this annotation

GitHub Actions / eslint

'mockAttachmentsV4Service' is assigned a value but never used
findOneAndUpdate: jest.fn(),
};

Expand All @@ -41,7 +41,6 @@
providers: [
{
provide: AttachmentsV4Service,
useValue: mockAttachmentsV4Service,
useValue: {
findOneAndUpdate: jest
.fn()
Expand Down
23 changes: 23 additions & 0 deletions src/casl/casl-ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { UserIdentity } from "src/users/schemas/user-identity.schema";
import { UserSettings } from "src/users/schemas/user-settings.schema";
import { User } from "src/users/schemas/user.schema";
import { Action } from "./action.enum";
import { RuntimeConfig } from "src/config/runtime-config/schemas/runtime-config.schema";
import { accessibleBy } from "@casl/mongoose";

type Subjects =
Expand All @@ -50,6 +51,7 @@ type Subjects =
| typeof UserSettings
| typeof ElasticSearchActions
| typeof Datablock
| typeof RuntimeConfig
>
| "all";
type PossibleAbilities = [Action, Subjects];
Expand Down Expand Up @@ -85,6 +87,7 @@ export class CaslAbilityFactory {
attachments: this.attachmentEndpointAccess,
history: this.historyEndpointAccess,
datablocks: this.datablockEndpointAccess,
runtimeconfig: this.runtimeConfigEndpointAccess,
};

endpointAccess(endpoint: string, user: JWTUser) {
Expand Down Expand Up @@ -911,6 +914,26 @@ export class CaslAbilityFactory {
item.constructor as ExtractSubjectType<Subjects>,
});
}
runtimeConfigEndpointAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
);

can(Action.Read, RuntimeConfig);
if (
user &&
user.currentGroups.some((g) => this.accessGroups?.admin.includes(g))
) {
/*
/ user that belongs to any of the group listed in ADMIN_GROUPS
*/
can(Action.Update, RuntimeConfig);
}
return build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}

policyEndpointAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
Expand Down
13 changes: 13 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@ const configuration = () => {
);

const config = {
configSyncToDb: {
configList: process.env.CONFIG_SYNC_TO_DB_LIST
? [
...new Set([
"frontendConfig",
"frontendTheme",
...(process.env.CONFIG_SYNC_TO_DB_LIST?.split(",").map((v) =>
v.trim(),
) ?? []),
]),
] // Always include frontendConfig and frontendTheme
: ["frontendConfig", "frontendTheme"],
},
maxFileUploadSizeInMb: process.env.MAX_FILE_UPLOAD_SIZE || "16mb", // 16MB by default
versions: {
api: "3",
Expand Down
39 changes: 39 additions & 0 deletions src/config/runtime-config/dto/output-runtime-config.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { IsString, IsObject, IsDateString } from "class-validator";

export class OutputRuntimeConfigDto {
/**
* Unique config identifier (e.g. 'frontendConfig', 'frontendTheme', etc.)
*/
@IsString()
cid: string;

/**
* Configuration content as a JSON object
*/
@IsObject()
data: Record<string, unknown>;

/**
* User or system that last updated the configuration
*/
@IsString()
updatedBy: string;

/**
* User or system that created the configuration
*/
@IsString()
createdBy: string;

/**
* Date/time when the configuration was created. ISO 8601 format.
*/
@IsDateString()
createdAt: Date;

/**
* Date/time when the configuration was last updated. ISO 8601 format.
*/
@IsDateString()
updatedAt: Date;
}
9 changes: 9 additions & 0 deletions src/config/runtime-config/dto/update-runtime-config.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IsObject } from "class-validator";

export class UpdateRuntimeConfigDto {
/**
* Configuration content as a JSON object
*/
@IsObject()
data: Record<string, unknown>;
}
Loading