Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
94 changes: 94 additions & 0 deletions docs/service-manifest-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Vault Web Service Manifest Schema

This document defines the standard JSON schema for external service dynamic integration manifests (`vault-service.json`).

Each service module in the Vault Web ecosystem (e.g. Cloud, Password Manager, Chats, Habits) must ship a conformant manifest file at its web root.

## Schema Definition

```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "VaultServiceManifest",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Unique identifier of the service module (e.g. 'cloud')."
},
"displayName": {
"type": "string",
"description": "Human-readable name displayed in menus (e.g. 'Cloud')."
},
"icon": {
"type": "string",
"description": "PrimeIcons class name for navigation icons (e.g. 'pi-cloud')."
},
"route": {
"type": "string",
"description": "Local Angular route to activate the service view (e.g. '/cloud')."
},
"baseUrl": {
"type": "string",
"description": "Base URL where the service module is deployed (e.g. 'http://localhost:8090')."
},
"healthEndpoint": {
"type": "string",
"description": "Endpoint path to verify the module's operational state (e.g. 'http://localhost:8090/api/health')."
},
"requiredScopes": {
"type": "array",
"items": {
"type": "string"
},
"description": "Required OAuth2/JWT scopes for interacting with this service."
},
"tokenForwarding": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether the API gateway should forward the portal token to this service."
},
"type": {
"type": "string",
"enum": ["header", "cookie", "query"],
"description": "Method of token transmission."
},
"name": {
"type": "string",
"description": "Header, cookie, or query parameter name for the token."
}
},
"required": ["enabled"]
}
},
"required": [
"name",
"displayName",
"icon",
"route",
"baseUrl",
"healthEndpoint"
]
}
```

## Example Manifest (`vault-service.json`)

```json
{
"name": "cloud",
"displayName": "Cloud",
"icon": "pi-cloud",
"route": "/cloud",
"baseUrl": "http://localhost:8090",
"healthEndpoint": "http://localhost:8090/api/health",
"requiredScopes": ["cloud:read", "cloud:write"],
"tokenForwarding": {
"enabled": true,
"type": "header",
"name": "Authorization"
}
}
```
15 changes: 15 additions & 0 deletions frontend/public/cloud-service.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "cloud",
"displayName": "Cloud",
"icon": "pi-cloud",
"route": "/cloud",
"baseUrl": "http://{host}:8090",
"apiUrl": "http://{host}:8090/api",
"healthEndpoint": "http://{host}:8090/api/health",
"requiredScopes": ["cloud:read", "cloud:write"],
"tokenForwarding": {
"enabled": true,
"type": "header",
"name": "Authorization"
}
}
15 changes: 15 additions & 0 deletions frontend/public/habits-service.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "habits",
"displayName": "Habits",
"icon": "pi-calendar-plus",
"route": "/habits",
"baseUrl": "http://{host}:8092",
"apiUrl": "http://{host}:8092/api",
"healthEndpoint": "http://{host}:8092/api/health",
"requiredScopes": ["habits:read"],
"tokenForwarding": {
"enabled": true,
"type": "header",
"name": "Authorization"
}
}
15 changes: 15 additions & 0 deletions frontend/public/password-manager-service.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "passwords",
"displayName": "Password Manager",
"icon": "pi-key",
"route": "/passwords",
"baseUrl": "http://{host}:8091",
"apiUrl": "http://{host}:8091/api",
"healthEndpoint": "http://{host}:8091/api/health",
"requiredScopes": ["passwords:read", "passwords:write"],
"tokenForwarding": {
"enabled": true,
"type": "header",
"name": "Authorization"
}
}
25 changes: 24 additions & 1 deletion frontend/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import {
ApplicationConfig,
provideZoneChangeDetection,
APP_INITIALIZER,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { providePrimeNG } from 'primeng/config';
import { MessageService } from 'primeng/api';
import Aura from '@primeuix/themes/aura';
import { routes } from './app.routes';
import { tokenInterceptor } from './core/interceptors/token.interceptor';
import { ServiceRegistryService } from './services/service-registry.service';
import { firstValueFrom } from 'rxjs';

export function initializeServices(registry: ServiceRegistryService) {
return () =>
firstValueFrom(registry.loadServices())
.then(() => {
registry.checkServicesHealth().subscribe();
})
.catch((err) => {
console.error('Service registry initialization failed', err);
});
}

export const appConfig: ApplicationConfig = {
providers: [
Expand All @@ -22,5 +39,11 @@ export const appConfig: ApplicationConfig = {
},
}),
MessageService,
{
provide: APP_INITIALIZER,
useFactory: initializeServices,
deps: [ServiceRegistryService],
multi: true,
},
],
};
91 changes: 68 additions & 23 deletions frontend/src/app/navbar/navbar.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,39 @@
routerLinkActive="is-active"
>Dashboard</a
>
<a routerLink="/cloud" class="navbar-link" routerLinkActive="is-active"
>Cloud</a
>
<a
routerLink="/passwords"
class="navbar-link"
routerLinkActive="is-active"
>Password Manager</a
>
<ng-container *ngFor="let service of services">
<ng-container *ngIf="service.isAvailable; else offlineLink">
<a
*ngIf="isExternalUrl(service.route); else internalLink"
[href]="service.route"
class="navbar-link"
>
{{ service.displayName }}
</a>
<ng-template #internalLink>
<a
[routerLink]="service.route"
class="navbar-link"
routerLinkActive="is-active"
>
{{ service.displayName }}
</a>
</ng-template>
</ng-container>
<ng-template #offlineLink>
<span
class="navbar-link opacity-50 cursor-not-allowed flex items-center"
[title]="service.displayName + ' is currently offline'"
>
{{ service.displayName }}
<span
class="bg-red-500 text-white rounded ml-1"
style="font-size: 0.6rem; padding: 1px 4px; line-height: 1"
>offline</span
>
</span>
</ng-template>
</ng-container>
<a
routerLink="/security-activity"
class="navbar-link"
Expand Down Expand Up @@ -146,20 +170,41 @@
routerLinkActive="is-active"
>Dashboard</a
>
<a
routerLink="/cloud"
(click)="closeMobileMenu()"
class="mobile-link block py-2"
routerLinkActive="is-active"
>Cloud</a
>
<a
routerLink="/passwords"
(click)="closeMobileMenu()"
class="mobile-link block py-2"
routerLinkActive="is-active"
>Password Manager</a
>
<ng-container *ngFor="let service of services">
<ng-container *ngIf="service.isAvailable; else offlineMobileLink">
<a
*ngIf="isExternalUrl(service.route); else internalMobileLink"
[href]="service.route"
(click)="closeMobileMenu()"
class="mobile-link block py-2"
>
{{ service.displayName }}
</a>
<ng-template #internalMobileLink>
<a
[routerLink]="service.route"
(click)="closeMobileMenu()"
class="mobile-link block py-2"
routerLinkActive="is-active"
>
{{ service.displayName }}
</a>
</ng-template>
</ng-container>
<ng-template #offlineMobileLink>
<span
class="mobile-link block py-2 opacity-50 cursor-not-allowed"
[title]="service.displayName + ' is currently offline'"
>
{{ service.displayName }}
<span
class="bg-red-500 text-white rounded ml-1"
style="font-size: 0.6rem; padding: 1px 4px; line-height: 1"
>offline</span
>
</span>
</ng-template>
</ng-container>
<a
routerLink="/security-activity"
(click)="closeMobileMenu()"
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/app/navbar/navbar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
ExternalDomainLink,
resolveExternalLinkUrl,
} from '../config/external-domains.config';
import {
ServiceRegistryService,
ServiceManifest,
} from '../services/service-registry.service';

@Component({
selector: 'app-navbar',
Expand All @@ -21,6 +25,7 @@ export class NavbarComponent implements OnInit {
isMobileMenuOpen = false;
isDomainDropdownOpen = false;
readonly externalDomainLinks: ExternalDomainLink[] = EXTERNAL_DOMAIN_LINKS;
services: ServiceManifest[] = [];

// Stores the full URL to the profile picture (e.g. "http://localhost:8080/uploads/...")
// null means no picture is set — the template will show the fallback initial instead
Expand All @@ -30,12 +35,16 @@ export class NavbarComponent implements OnInit {
public themeService: ThemeService,
public authService: AuthService,
private userService: UserService,
private serviceRegistry: ServiceRegistryService,
) {}

/**
* Loads the profile picture when the navbar initializes.
*/
ngOnInit(): void {
this.serviceRegistry.services$.subscribe((services) => {
this.services = services;
});
if (this.authService.isLoggedIn()) {
// Subscribe to reactive profile picture updates
this.userService.profilePicUrl$.subscribe((url) => {
Expand All @@ -50,6 +59,10 @@ export class NavbarComponent implements OnInit {
}
}

isExternalUrl(url: string): boolean {
return !!url && (url.startsWith('http://') || url.startsWith('https://'));
}

/**
* Returns the first letter of the username, uppercased.
* Used as the fallback avatar text when no profile picture is set.
Expand Down
Loading