up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-11 08:20:15 +02:00
parent b8b493913a
commit ce1f282ce0
65 changed files with 5481 additions and 1803 deletions

View File

@@ -0,0 +1,485 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { CONSOLE_API_BASE_URL } from './console-status.client';
import {
ConsoleSearchResponse,
ConsoleSearchQueryOptions,
ConsoleDownloadResponse,
ConsoleDownloadQueryOptions,
SearchResultItem,
SearchSeverity,
SearchPolicyBadge,
SearchReachability,
SearchVexState,
DownloadManifest,
DownloadManifestItem,
} from './console-search.models';
import { generateTraceId } from './trace.util';
/**
* Console Search & Downloads API interface.
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
*/
export interface ConsoleSearchApi {
/** Search with deterministic ranking and caching. */
search(options?: ConsoleSearchQueryOptions): Observable<ConsoleSearchResponse>;
/** Get download manifest. */
getDownloads(options?: ConsoleDownloadQueryOptions): Observable<ConsoleDownloadResponse>;
/** Get download manifest for specific export. */
getDownload(exportId: string, options?: ConsoleDownloadQueryOptions): Observable<ConsoleDownloadResponse>;
}
export const CONSOLE_SEARCH_API = new InjectionToken<ConsoleSearchApi>('CONSOLE_SEARCH_API');
/**
* Deterministic ranking comparator.
* Order: severity (desc) → exploitScore (desc) → reachability (reachable > unknown > unreachable)
* → policyBadge (fail > warn > pass > waived) → vexState (under_investigation > fixed > not_affected > unknown)
* → findingId (asc)
*/
function compareSearchResults(a: SearchResultItem, b: SearchResultItem): number {
// Severity order (higher = more severe)
const severityOrder: Record<SearchSeverity, number> = {
critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0,
};
const sevDiff = severityOrder[b.severity] - severityOrder[a.severity];
if (sevDiff !== 0) return sevDiff;
// Exploit score desc
const exploitDiff = (b.exploitScore ?? 0) - (a.exploitScore ?? 0);
if (exploitDiff !== 0) return exploitDiff;
// Reachability order (reachable > unknown > unreachable)
const reachOrder: Record<SearchReachability, number> = {
reachable: 2, unknown: 1, unreachable: 0,
};
const reachA = a.reachability ?? 'unknown';
const reachB = b.reachability ?? 'unknown';
const reachDiff = reachOrder[reachB] - reachOrder[reachA];
if (reachDiff !== 0) return reachDiff;
// Policy badge order (fail > warn > pass > waived)
const badgeOrder: Record<SearchPolicyBadge, number> = {
fail: 3, warn: 2, pass: 1, waived: 0,
};
const badgeA = a.policyBadge ?? 'pass';
const badgeB = b.policyBadge ?? 'pass';
const badgeDiff = badgeOrder[badgeB] - badgeOrder[badgeA];
if (badgeDiff !== 0) return badgeDiff;
// VEX state order (under_investigation > fixed > not_affected > unknown)
const vexOrder: Record<SearchVexState, number> = {
under_investigation: 3, fixed: 2, not_affected: 1, unknown: 0,
};
const vexA = a.vexState ?? 'unknown';
const vexB = b.vexState ?? 'unknown';
const vexDiff = vexOrder[vexB] - vexOrder[vexA];
if (vexDiff !== 0) return vexDiff;
// Secondary: advisoryId asc, then product asc
const advDiff = (a.advisoryId ?? '').localeCompare(b.advisoryId ?? '');
if (advDiff !== 0) return advDiff;
const prodDiff = (a.product ?? '').localeCompare(b.product ?? '');
if (prodDiff !== 0) return prodDiff;
// Final: findingId asc
return a.findingId.localeCompare(b.findingId);
}
/**
* Compute SHA-256 hash of sorted payload (simplified for client-side).
*/
function computePayloadHash(items: readonly SearchResultItem[]): string {
// Simplified: create deterministic string from sorted items
const payload = items.map(i => `${i.findingId}:${i.severity}:${i.exploitScore ?? 0}`).join('|');
// In production, use actual SHA-256; here we use a simple hash
let hash = 0;
for (let i = 0; i < payload.length; i++) {
const char = payload.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return `sha256:${Math.abs(hash).toString(16).padStart(16, '0')}`;
}
/**
* HTTP Console Search Client.
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
*/
@Injectable({ providedIn: 'root' })
export class ConsoleSearchHttpClient implements ConsoleSearchApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string
) {}
search(options: ConsoleSearchQueryOptions = {}): Observable<ConsoleSearchResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read scope'));
}
const headers = this.buildHeaders(options);
const params = this.buildSearchParams(options);
return this.http.get<ConsoleSearchResponse>(`${this.baseUrl}/search`, { headers, params }).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getDownloads(options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.format) {
params = params.set('format', options.format);
}
if (options.includeDsse) {
params = params.set('includeDsse', 'true');
}
return this.http.get<ConsoleDownloadResponse>(`${this.baseUrl}/downloads`, { headers, params }).pipe(
map((response) => ({
...response,
manifest: { ...response.manifest, traceId },
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getDownload(exportId: string, options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.format) {
params = params.set('format', options.format);
}
if (options.includeDsse) {
params = params.set('includeDsse', 'true');
}
return this.http.get<ConsoleDownloadResponse>(
`${this.baseUrl}/downloads/${encodeURIComponent(exportId)}`,
{ headers, params }
).pipe(
map((response) => ({
...response,
manifest: { ...response.manifest, traceId },
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
let headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
if (opts.ifNoneMatch) {
headers = headers.set('If-None-Match', opts.ifNoneMatch);
}
return headers;
}
private buildSearchParams(opts: ConsoleSearchQueryOptions): HttpParams {
let params = new HttpParams();
if (opts.pageToken) {
params = params.set('pageToken', opts.pageToken);
}
if (opts.pageSize) {
params = params.set('pageSize', String(opts.pageSize));
}
if (opts.query) {
params = params.set('query', opts.query);
}
if (opts.severity?.length) {
params = params.set('severity', opts.severity.join(','));
}
if (opts.reachability?.length) {
params = params.set('reachability', opts.reachability.join(','));
}
if (opts.policyBadge?.length) {
params = params.set('policyBadge', opts.policyBadge.join(','));
}
if (opts.vexState?.length) {
params = params.set('vexState', opts.vexState.join(','));
}
if (opts.projectId) {
params = params.set('projectId', opts.projectId);
}
return params;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ConsoleSearchClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Console search error: ${err.message}`);
}
return new Error(`[${traceId}] Console search error: Unknown error`);
}
}
/**
* Mock Console Search API for quickstart mode.
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
*/
@Injectable({ providedIn: 'root' })
export class MockConsoleSearchClient implements ConsoleSearchApi {
private readonly mockResults: SearchResultItem[] = [
{
findingId: 'tenant-default:advisory-ai:sha256:9bf4',
advisoryId: 'CVE-2024-67890',
severity: 'critical',
exploitScore: 9.1,
reachability: 'reachable',
policyBadge: 'fail',
vexState: 'under_investigation',
product: 'registry.local/ops/transform:2025.10.0',
summary: 'lodash prototype pollution in _.set and related functions.',
lastUpdated: '2025-11-08T10:30:00Z',
},
{
findingId: 'tenant-default:advisory-ai:sha256:5d1a',
advisoryId: 'CVE-2024-12345',
severity: 'high',
exploitScore: 8.1,
reachability: 'reachable',
policyBadge: 'fail',
vexState: 'under_investigation',
product: 'registry.local/ops/auth:2025.10.0',
summary: 'jsonwebtoken <10.0.0 allows algorithm downgrade.',
lastUpdated: '2025-11-07T23:16:51Z',
},
{
findingId: 'tenant-default:advisory-ai:sha256:abc1',
advisoryId: 'CVE-2024-11111',
severity: 'medium',
exploitScore: 5.3,
reachability: 'unreachable',
policyBadge: 'warn',
vexState: 'not_affected',
product: 'registry.local/ops/gateway:2025.10.0',
summary: 'Express.js path traversal vulnerability.',
lastUpdated: '2025-11-06T14:00:00Z',
},
{
findingId: 'tenant-default:advisory-ai:sha256:def2',
advisoryId: 'CVE-2024-22222',
severity: 'low',
exploitScore: 3.0,
reachability: 'unknown',
policyBadge: 'pass',
vexState: 'fixed',
product: 'registry.local/ops/cache:2025.10.0',
summary: 'Cache timing side channel.',
lastUpdated: '2025-11-05T09:00:00Z',
},
];
search(options: ConsoleSearchQueryOptions = {}): Observable<ConsoleSearchResponse> {
const traceId = options.traceId ?? generateTraceId();
let filtered = [...this.mockResults];
// Apply filters
if (options.query) {
const queryLower = options.query.toLowerCase();
filtered = filtered.filter((r) =>
r.advisoryId.toLowerCase().includes(queryLower) ||
r.summary?.toLowerCase().includes(queryLower) ||
r.product?.toLowerCase().includes(queryLower)
);
}
if (options.severity?.length) {
filtered = filtered.filter((r) => options.severity!.includes(r.severity));
}
if (options.reachability?.length) {
filtered = filtered.filter((r) => r.reachability && options.reachability!.includes(r.reachability));
}
if (options.policyBadge?.length) {
filtered = filtered.filter((r) => r.policyBadge && options.policyBadge!.includes(r.policyBadge));
}
if (options.vexState?.length) {
filtered = filtered.filter((r) => r.vexState && options.vexState!.includes(r.vexState));
}
// Apply deterministic ranking
filtered.sort(compareSearchResults);
// Paginate
const pageSize = options.pageSize ?? 50;
const items = filtered.slice(0, pageSize);
// Compute ranking metadata
const payloadHash = computePayloadHash(items);
const newestUpdatedAt = items.reduce((newest, item) => {
if (!item.lastUpdated) return newest;
return !newest || item.lastUpdated > newest ? item.lastUpdated : newest;
}, '' as string);
const response: ConsoleSearchResponse = {
items,
ranking: {
sortKeys: ['severity', 'exploitScore', 'reachability', 'policyBadge', 'vexState', 'findingId'],
payloadHash,
newestUpdatedAt: newestUpdatedAt || undefined,
},
nextPageToken: filtered.length > pageSize ? this.createCursor(items[items.length - 1], traceId) : null,
total: filtered.length,
traceId,
etag: `"${payloadHash}"`,
cacheControl: 'public, max-age=300, stale-while-revalidate=60, stale-if-error=300',
};
return of(response).pipe(delay(50));
}
getDownloads(options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
const traceId = options.traceId ?? generateTraceId();
const tenant = options.tenantId ?? 'tenant-default';
const exportId = `console-export::${tenant}::${new Date().toISOString().split('T')[0]}::0001`;
const manifest = this.createMockManifest(exportId, tenant, traceId, options.includeDsse);
return of({
manifest,
etag: `"${manifest.checksums.manifest}"`,
cacheControl: 'public, max-age=300, stale-while-revalidate=60, stale-if-error=300',
}).pipe(delay(50));
}
getDownload(exportId: string, options: ConsoleDownloadQueryOptions = {}): Observable<ConsoleDownloadResponse> {
const traceId = options.traceId ?? generateTraceId();
const tenant = options.tenantId ?? 'tenant-default';
const manifest = this.createMockManifest(exportId, tenant, traceId, options.includeDsse);
return of({
manifest,
etag: `"${manifest.checksums.manifest}"`,
cacheControl: 'public, max-age=300, stale-while-revalidate=60, stale-if-error=300',
}).pipe(delay(30));
}
private createMockManifest(
exportId: string,
tenantId: string,
traceId: string,
includeDsse?: boolean
): DownloadManifest {
const now = new Date();
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
// Sort items deterministically: type asc, id asc, format asc
const items: DownloadManifestItem[] = [
{
type: 'advisory',
id: 'CVE-2024-12345',
format: 'json',
url: `https://downloads.local/exports/${exportId}/advisory/CVE-2024-12345.json?sig=mock`,
sha256: 'sha256:a1b2c3d4e5f6',
size: 4096,
},
{
type: 'advisory',
id: 'CVE-2024-67890',
format: 'json',
url: `https://downloads.local/exports/${exportId}/advisory/CVE-2024-67890.json?sig=mock`,
sha256: 'sha256:f6e5d4c3b2a1',
size: 3072,
},
{
type: 'vex',
id: 'vex:tenant-default:jwt-auth:5d1a',
format: 'json',
url: `https://downloads.local/exports/${exportId}/vex/jwt-auth-5d1a.json?sig=mock`,
sha256: 'sha256:1a2b3c4d5e6f',
size: 2048,
},
{
type: 'vuln',
id: 'tenant-default:advisory-ai:sha256:5d1a',
format: 'json',
url: `https://downloads.local/exports/${exportId}/vuln/5d1a.json?sig=mock`,
sha256: 'sha256:6f5e4d3c2b1a',
size: 8192,
},
].sort((a, b) => {
const typeDiff = a.type.localeCompare(b.type);
if (typeDiff !== 0) return typeDiff;
const idDiff = a.id.localeCompare(b.id);
if (idDiff !== 0) return idDiff;
return a.format.localeCompare(b.format);
});
const manifestHash = `sha256:${Math.abs(exportId.split('').reduce((h, c) => ((h << 5) - h) + c.charCodeAt(0), 0)).toString(16).padStart(16, '0')}`;
return {
version: '2025-12-07',
exportId,
tenantId,
generatedAt: now.toISOString(),
items,
checksums: {
manifest: manifestHash,
bundle: `sha256:bundle${Date.now().toString(16)}`,
},
expiresAt: expiresAt.toISOString(),
dsseUrl: includeDsse ? `https://downloads.local/exports/${exportId}/manifest.dsse?sig=mock` : undefined,
traceId,
};
}
private createCursor(lastItem: SearchResultItem, tenantId: string): string {
// Create opaque, signed cursor with sortKeys and tenant
const cursorData = {
findingId: lastItem.findingId,
severity: lastItem.severity,
exploitScore: lastItem.exploitScore,
tenant: tenantId,
};
// In production, this would be signed and base64url encoded
return Buffer.from(JSON.stringify(cursorData)).toString('base64url');
}
}

View File

@@ -0,0 +1,134 @@
/**
* Console Search & Downloads Models.
* Implements WEB-CONSOLE-23-004 and WEB-CONSOLE-23-005.
*/
/** Severity levels for ranking. */
export type SearchSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
/** Policy badge for ranking. */
export type SearchPolicyBadge = 'fail' | 'warn' | 'pass' | 'waived';
/** Reachability status for ranking. */
export type SearchReachability = 'reachable' | 'unknown' | 'unreachable';
/** VEX state for ranking. */
export type SearchVexState = 'under_investigation' | 'fixed' | 'not_affected' | 'unknown';
/** Search result item base. */
export interface SearchResultItem {
readonly findingId: string;
readonly advisoryId: string;
readonly severity: SearchSeverity;
readonly exploitScore?: number;
readonly reachability?: SearchReachability;
readonly policyBadge?: SearchPolicyBadge;
readonly vexState?: SearchVexState;
readonly product?: string;
readonly summary?: string;
readonly lastUpdated?: string;
}
/** Search result ranking metadata. */
export interface SearchRankingMeta {
/** Sort keys used for deterministic ordering. */
readonly sortKeys: string[];
/** SHA-256 of sorted payload for ETag. */
readonly payloadHash: string;
/** Newest updatedAt in result set. */
readonly newestUpdatedAt?: string;
}
/** Paginated search response. */
export interface ConsoleSearchResponse {
readonly items: readonly SearchResultItem[];
readonly ranking: SearchRankingMeta;
readonly nextPageToken?: string | null;
readonly total: number;
readonly traceId?: string;
readonly etag?: string;
readonly cacheControl?: string;
}
/** Search query options. */
export interface ConsoleSearchQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly query?: string;
readonly severity?: readonly SearchSeverity[];
readonly reachability?: readonly SearchReachability[];
readonly policyBadge?: readonly SearchPolicyBadge[];
readonly vexState?: readonly SearchVexState[];
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Download manifest item types. */
export type DownloadItemType = 'vuln' | 'advisory' | 'vex' | 'policy' | 'scan' | 'chart' | 'bundle';
/** Download manifest item. */
export interface DownloadManifestItem {
readonly type: DownloadItemType;
readonly id: string;
readonly format: string;
readonly url: string;
readonly sha256: string;
readonly size: number;
}
/** Download manifest checksums. */
export interface DownloadManifestChecksums {
readonly manifest: string;
readonly bundle?: string;
}
/** Download manifest structure. */
export interface DownloadManifest {
readonly version: string;
readonly exportId: string;
readonly tenantId: string;
readonly generatedAt: string;
readonly items: readonly DownloadManifestItem[];
readonly checksums: DownloadManifestChecksums;
readonly expiresAt: string;
/** Optional DSSE envelope URL. */
readonly dsseUrl?: string;
readonly traceId?: string;
}
/** Download response. */
export interface ConsoleDownloadResponse {
readonly manifest: DownloadManifest;
readonly etag?: string;
readonly cacheControl?: string;
}
/** Download query options. */
export interface ConsoleDownloadQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly exportId?: string;
readonly format?: string;
readonly includeDsse?: boolean;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Error codes for search/downloads. */
export type ConsoleSearchDownloadErrorCode =
| 'ERR_CONSOLE_DOWNLOAD_INVALID_CURSOR'
| 'ERR_CONSOLE_DOWNLOAD_EXPIRED'
| 'ERR_CONSOLE_DOWNLOAD_RATE_LIMIT'
| 'ERR_CONSOLE_DOWNLOAD_UNAVAILABLE'
| 'ERR_CONSOLE_SEARCH_INVALID_QUERY'
| 'ERR_CONSOLE_SEARCH_RATE_LIMIT';
/** Error response. */
export interface ConsoleSearchDownloadError {
readonly code: ConsoleSearchDownloadErrorCode;
readonly message: string;
readonly requestId: string;
readonly retryAfterSeconds?: number;
}

View File

@@ -0,0 +1,431 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError, Subject } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
CONSOLE_API_BASE_URL,
EVENT_SOURCE_FACTORY,
EventSourceFactory,
DEFAULT_EVENT_SOURCE_FACTORY,
} from './console-status.client';
import {
VexStatement,
VexStatementsResponse,
VexStatementsQueryOptions,
VexStatementDetail,
VexStreamEvent,
VexEventsQueryOptions,
VexStatus,
VexSourceType,
} from './console-vex.models';
import { generateTraceId } from './trace.util';
/**
* Console VEX API interface.
* Implements CONSOLE-VEX-30-001.
*/
export interface ConsoleVexApi {
/** List VEX statements with pagination and filters. */
listStatements(options?: VexStatementsQueryOptions): Observable<VexStatementsResponse>;
/** Get full VEX statement detail by ID. */
getStatement(statementId: string, options?: VexStatementsQueryOptions): Observable<VexStatementDetail>;
/** Subscribe to VEX events stream (SSE). */
streamEvents(options?: VexEventsQueryOptions): Observable<VexStreamEvent>;
}
export const CONSOLE_VEX_API = new InjectionToken<ConsoleVexApi>('CONSOLE_VEX_API');
/**
* HTTP Console VEX Client.
* Implements CONSOLE-VEX-30-001 with tenant scoping, RBAC, and SSE streaming.
*/
@Injectable({ providedIn: 'root' })
export class ConsoleVexHttpClient implements ConsoleVexApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory = DEFAULT_EVENT_SOURCE_FACTORY
) {}
listStatements(options: VexStatementsQueryOptions = {}): Observable<VexStatementsResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vex:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vex:read scope'));
}
const headers = this.buildHeaders(options);
const params = this.buildStatementsParams(options);
return this.http.get<VexStatementsResponse>(`${this.baseUrl}/vex/statements`, { headers, params }).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getStatement(statementId: string, options: VexStatementsQueryOptions = {}): Observable<VexStatementDetail> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vex:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vex:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<VexStatementDetail>(
`${this.baseUrl}/vex/statements/${encodeURIComponent(statementId)}`,
{ headers }
).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
streamEvents(options: VexEventsQueryOptions = {}): Observable<VexStreamEvent> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
let url = `${this.baseUrl}/vex/events?tenant=${encodeURIComponent(tenant)}&traceId=${encodeURIComponent(traceId)}`;
if (options.projectId) {
url += `&projectId=${encodeURIComponent(options.projectId)}`;
}
return new Observable<VexStreamEvent>((observer) => {
const eventSource = this.eventSourceFactory(url);
// Set Last-Event-ID header for replay support
if (options.lastEventId && 'lastEventId' in eventSource) {
// Note: EventSource doesn't allow setting headers directly,
// so we include lastEventId as query param instead
url += `&lastEventId=${encodeURIComponent(options.lastEventId)}`;
}
const handleEvent = (eventType: string) => (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
observer.next({
event: eventType as VexStreamEvent['event'],
...data,
traceId,
});
} catch (err) {
// Skip invalid JSON (e.g., keepalive with empty data)
if (eventType === 'keepalive') {
observer.next({
event: 'keepalive',
sequence: Date.now(),
traceId,
});
}
}
};
eventSource.addEventListener('statement.created', handleEvent('statement.created'));
eventSource.addEventListener('statement.updated', handleEvent('statement.updated'));
eventSource.addEventListener('statement.deleted', handleEvent('statement.deleted'));
eventSource.addEventListener('statement.conflict', handleEvent('statement.conflict'));
eventSource.addEventListener('keepalive', handleEvent('keepalive'));
eventSource.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as VexStreamEvent;
observer.next({ ...parsed, traceId });
} catch {
// Ignore parse errors for default messages
}
};
eventSource.onerror = (err) => {
observer.error(new Error(`[${traceId}] VEX events stream error`));
eventSource.close();
};
return () => {
eventSource.close();
};
});
}
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
let headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
if (opts.ifNoneMatch) {
headers = headers.set('If-None-Match', opts.ifNoneMatch);
}
return headers;
}
private buildStatementsParams(opts: VexStatementsQueryOptions): HttpParams {
let params = new HttpParams();
if (opts.pageToken) {
params = params.set('pageToken', opts.pageToken);
}
if (opts.pageSize) {
params = params.set('pageSize', String(opts.pageSize));
}
if (opts.advisoryId?.length) {
params = params.set('advisoryId', opts.advisoryId.join(','));
}
if (opts.justification?.length) {
params = params.set('justification', opts.justification.join(','));
}
if (opts.statementType?.length) {
params = params.set('statementType', opts.statementType.join(','));
}
if (opts.search) {
params = params.set('search', opts.search);
}
if (opts.projectId) {
params = params.set('projectId', opts.projectId);
}
if (opts.prefer) {
params = params.set('prefer', opts.prefer);
}
return params;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ConsoleVexClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Console VEX error: ${err.message}`);
}
return new Error(`[${traceId}] Console VEX error: Unknown error`);
}
}
/**
* Mock Console VEX API for quickstart mode.
* Implements CONSOLE-VEX-30-001.
*/
@Injectable({ providedIn: 'root' })
export class MockConsoleVexClient implements ConsoleVexApi {
private readonly eventSubject = new Subject<VexStreamEvent>();
private eventSequence = 1000;
private readonly mockStatements: VexStatement[] = [
{
statementId: 'vex:tenant-default:jwt-auth:5d1a',
advisoryId: 'CVE-2024-12345',
product: 'registry.local/ops/auth:2025.10.0',
status: 'under_investigation',
justification: 'exploit_observed',
lastUpdated: '2025-11-07T23:10:09Z',
source: {
type: 'advisory_ai',
modelBuild: 'aiai-console-2025-10-28',
confidence: 0.74,
},
links: [
{
rel: 'finding',
href: '/console/vuln/findings/tenant-default:advisory-ai:sha256:5d1a',
},
],
},
{
statementId: 'vex:tenant-default:data-transform:9bf4',
advisoryId: 'CVE-2024-67890',
product: 'registry.local/ops/transform:2025.10.0',
status: 'affected',
justification: 'exploit_observed',
lastUpdated: '2025-11-08T10:30:00Z',
source: {
type: 'vex',
confidence: 0.95,
},
links: [
{
rel: 'finding',
href: '/console/vuln/findings/tenant-default:advisory-ai:sha256:9bf4',
},
],
},
{
statementId: 'vex:tenant-default:api-gateway:abc1',
advisoryId: 'CVE-2024-11111',
product: 'registry.local/ops/gateway:2025.10.0',
status: 'not_affected',
justification: 'inline_mitigations_exist',
lastUpdated: '2025-11-06T14:00:00Z',
source: {
type: 'custom',
confidence: 1.0,
},
},
{
statementId: 'vex:tenant-default:cache:def2',
advisoryId: 'CVE-2024-22222',
product: 'registry.local/ops/cache:2025.10.0',
status: 'fixed',
justification: 'solution_available',
lastUpdated: '2025-11-05T09:00:00Z',
source: {
type: 'openvex',
confidence: 1.0,
},
},
];
listStatements(options: VexStatementsQueryOptions = {}): Observable<VexStatementsResponse> {
const traceId = options.traceId ?? generateTraceId();
let filtered = [...this.mockStatements];
// Apply filters
if (options.advisoryId?.length) {
filtered = filtered.filter((s) => options.advisoryId!.includes(s.advisoryId));
}
if (options.justification?.length) {
filtered = filtered.filter((s) => s.justification && options.justification!.includes(s.justification));
}
if (options.statementType?.length) {
filtered = filtered.filter((s) => s.source && options.statementType!.includes(s.source.type));
}
if (options.search) {
const searchLower = options.search.toLowerCase();
filtered = filtered.filter((s) =>
s.advisoryId.toLowerCase().includes(searchLower) ||
s.product.toLowerCase().includes(searchLower)
);
}
// Sort: lastUpdated desc, statementId asc
filtered.sort((a, b) => {
const dateDiff = new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime();
if (dateDiff !== 0) return dateDiff;
return a.statementId.localeCompare(b.statementId);
});
// Paginate
const pageSize = options.pageSize ?? 50;
const items = filtered.slice(0, pageSize);
const response: VexStatementsResponse = {
items,
nextPageToken: filtered.length > pageSize ? 'mock-next-page' : null,
total: filtered.length,
traceId,
};
return of(response).pipe(delay(50));
}
getStatement(statementId: string, options: VexStatementsQueryOptions = {}): Observable<VexStatementDetail> {
const traceId = options.traceId ?? generateTraceId();
const statement = this.mockStatements.find((s) => s.statementId === statementId);
if (!statement) {
return throwError(() => new Error(`Statement ${statementId} not found`));
}
const detail: VexStatementDetail = {
...statement,
provenance: {
documentId: `tenant-default:vex:${statementId}`,
observationPath: '/statements/0',
recordedAt: statement.lastUpdated,
},
impactStatement: 'Service may be impacted until remediation is applied.',
remediations: [
{
type: 'patch',
description: 'Upgrade to the latest patched version.',
deadline: '2025-12-15T00:00:00Z',
},
],
etag: `"vex-${statementId}-${Date.now()}"`,
traceId,
};
return of(detail).pipe(delay(30));
}
streamEvents(options: VexEventsQueryOptions = {}): Observable<VexStreamEvent> {
const traceId = options.traceId ?? generateTraceId();
// Return observable that emits events
return new Observable<VexStreamEvent>((observer) => {
// Subscribe to internal subject
const subscription = this.eventSubject.subscribe((event) => {
observer.next({ ...event, traceId });
});
// Send initial keepalive
observer.next({
event: 'keepalive',
sequence: this.eventSequence++,
traceId,
});
// Emit mock events periodically for testing
const interval = setInterval(() => {
observer.next({
event: 'keepalive',
sequence: this.eventSequence++,
traceId,
});
}, 15000); // Every 15 seconds
return () => {
subscription.unsubscribe();
clearInterval(interval);
};
});
}
/** Trigger a mock event for testing. */
triggerMockEvent(event: Omit<VexStreamEvent, 'sequence'>): void {
this.eventSubject.next({
...event,
sequence: this.eventSequence++,
});
}
/** Simulate a statement update event. */
simulateStatementUpdate(statementId: string, newStatus: VexStatus): void {
const statement = this.mockStatements.find((s) => s.statementId === statementId);
if (statement) {
this.eventSubject.next({
event: 'statement.updated',
statementId,
advisoryId: statement.advisoryId,
product: statement.product,
state: newStatus,
sequence: this.eventSequence++,
updatedAt: new Date().toISOString(),
});
}
}
}

View File

@@ -0,0 +1,136 @@
/**
* Console VEX Workspace Models.
* Implements CONSOLE-VEX-30-001.
*/
/** VEX status values. */
export type VexStatus =
| 'not_affected'
| 'fixed'
| 'under_investigation'
| 'affected'
| 'unknown'
| 'unavailable';
/** VEX justification values. */
export type VexJustification =
| 'exploit_observed'
| 'component_not_present'
| 'vulnerable_code_not_present'
| 'vulnerable_code_not_in_execute_path'
| 'inline_mitigations_exist'
| 'vulnerable_code_cannot_be_controlled_by_adversary'
| 'solution_available'
| 'workaround_available'
| 'no_impact'
| 'unknown';
/** VEX statement source type. */
export type VexSourceType = 'vex' | 'openvex' | 'custom' | 'advisory_ai';
/** VEX statement source. */
export interface VexStatementSource {
readonly type: VexSourceType;
readonly modelBuild?: string;
readonly confidence?: number;
}
/** Related link in VEX statement. */
export interface VexStatementLink {
readonly rel: string;
readonly href: string;
}
/** VEX statement item. */
export interface VexStatement {
readonly statementId: string;
readonly advisoryId: string;
readonly product: string;
readonly status: VexStatus;
readonly justification?: VexJustification | string;
readonly lastUpdated: string;
readonly source?: VexStatementSource;
readonly links?: readonly VexStatementLink[];
}
/** VEX statement conflict info. */
export interface VexConflict {
readonly conflictId: string;
readonly statementIds: readonly string[];
readonly conflictType: string;
readonly summary: string;
readonly resolvedAt?: string;
}
/** Paginated VEX statements response. */
export interface VexStatementsResponse {
readonly items: readonly VexStatement[];
readonly conflicts?: readonly VexConflict[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Query options for VEX statements. */
export interface VexStatementsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly advisoryId?: readonly string[];
readonly justification?: readonly string[];
readonly statementType?: readonly VexSourceType[];
readonly search?: string;
readonly prefer?: 'json' | 'stream';
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Full VEX statement detail. */
export interface VexStatementDetail extends VexStatement {
readonly provenance?: {
readonly documentId: string;
readonly observationPath?: string;
readonly recordedAt: string;
};
readonly impactStatement?: string;
readonly remediations?: readonly {
readonly type: string;
readonly description: string;
readonly deadline?: string;
}[];
readonly etag?: string;
readonly traceId?: string;
}
/** SSE event types for VEX workspace. */
export type VexEventType =
| 'statement.created'
| 'statement.updated'
| 'statement.deleted'
| 'statement.conflict'
| 'keepalive';
/** VEX SSE event payload. */
export interface VexStreamEvent {
readonly event: VexEventType;
readonly statementId?: string;
readonly advisoryId?: string;
readonly product?: string;
readonly state?: VexStatus;
readonly justification?: string;
readonly severityHint?: string;
readonly policyBadge?: string;
readonly conflictSummary?: string;
readonly sequence: number;
readonly updatedAt?: string;
readonly traceId?: string;
}
/** Query options for VEX events stream. */
export interface VexEventsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly lastEventId?: string;
readonly traceId?: string;
}

View File

@@ -0,0 +1,482 @@
import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { CONSOLE_API_BASE_URL } from './console-status.client';
import {
VulnFinding,
VulnFindingsResponse,
VulnFindingsQueryOptions,
VulnFindingDetail,
VulnFindingQueryOptions,
VulnFacets,
VulnTicketRequest,
VulnTicketResponse,
VulnSeverity,
PolicyBadge,
VexState,
ReachabilityStatus,
} from './console-vuln.models';
import { generateTraceId } from './trace.util';
/**
* Console Vuln API interface.
* Implements CONSOLE-VULN-29-001.
*/
export interface ConsoleVulnApi {
/** List findings with pagination and filters. */
listFindings(options?: VulnFindingsQueryOptions): Observable<VulnFindingsResponse>;
/** Get facets for sidebar filters. */
getFacets(options?: VulnFindingsQueryOptions): Observable<VulnFacets>;
/** Get full finding detail by ID. */
getFinding(findingId: string, options?: VulnFindingQueryOptions): Observable<VulnFindingDetail>;
/** Export findings to ticketing system. */
createTicket(request: VulnTicketRequest, options?: VulnFindingQueryOptions): Observable<VulnTicketResponse>;
}
export const CONSOLE_VULN_API = new InjectionToken<ConsoleVulnApi>('CONSOLE_VULN_API');
/**
* HTTP Console Vuln Client.
* Implements CONSOLE-VULN-29-001 with tenant scoping and RBAC.
*/
@Injectable({ providedIn: 'root' })
export class ConsoleVulnHttpClient implements ConsoleVulnApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(CONSOLE_API_BASE_URL) private readonly baseUrl: string
) {}
listFindings(options: VulnFindingsQueryOptions = {}): Observable<VulnFindingsResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vuln:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vuln:read scope'));
}
const headers = this.buildHeaders(options);
const params = this.buildFindingsParams(options);
return this.http.get<VulnFindingsResponse>(`${this.baseUrl}/vuln/findings`, { headers, params }).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getFacets(options: VulnFindingsQueryOptions = {}): Observable<VulnFacets> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vuln:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vuln:read scope'));
}
const headers = this.buildHeaders(options);
const params = this.buildFindingsParams(options);
return this.http.get<VulnFacets>(`${this.baseUrl}/vuln/facets`, { headers, params }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getFinding(findingId: string, options: VulnFindingQueryOptions = {}): Observable<VulnFindingDetail> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'read', ['console:read', 'vuln:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:read or vuln:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<VulnFindingDetail>(
`${this.baseUrl}/vuln/${encodeURIComponent(findingId)}`,
{ headers }
).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
createTicket(request: VulnTicketRequest, options: VulnFindingQueryOptions = {}): Observable<VulnTicketResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('console', 'write', ['console:read', 'vuln:read', 'console:export'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing console:export scope'));
}
const headers = this.buildHeaders(options);
return this.http.post<VulnTicketResponse>(`${this.baseUrl}/vuln/tickets`, request, { headers }).pipe(
map((response) => ({
...response,
traceId,
})),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
let headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
if (opts.ifNoneMatch) {
headers = headers.set('If-None-Match', opts.ifNoneMatch);
}
return headers;
}
private buildFindingsParams(opts: VulnFindingsQueryOptions): HttpParams {
let params = new HttpParams();
if (opts.pageToken) {
params = params.set('pageToken', opts.pageToken);
}
if (opts.pageSize) {
params = params.set('pageSize', String(opts.pageSize));
}
if (opts.severity?.length) {
params = params.set('severity', opts.severity.join(','));
}
if (opts.product?.length) {
params = params.set('product', opts.product.join(','));
}
if (opts.policyBadge?.length) {
params = params.set('policyBadge', opts.policyBadge.join(','));
}
if (opts.vexState?.length) {
params = params.set('vexState', opts.vexState.join(','));
}
if (opts.reachability?.length) {
params = params.set('reachability', opts.reachability.join(','));
}
if (opts.search) {
params = params.set('search', opts.search);
}
if (opts.projectId) {
params = params.set('projectId', opts.projectId);
}
return params;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ConsoleVulnClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Console vuln error: ${err.message}`);
}
return new Error(`[${traceId}] Console vuln error: Unknown error`);
}
}
/**
* Mock Console Vuln API for quickstart mode.
* Implements CONSOLE-VULN-29-001.
*/
@Injectable({ providedIn: 'root' })
export class MockConsoleVulnClient implements ConsoleVulnApi {
private readonly mockFindings: VulnFinding[] = [
{
findingId: 'tenant-default:advisory-ai:sha256:5d1a',
coordinates: {
advisoryId: 'CVE-2024-12345',
package: 'pkg:npm/jsonwebtoken@9.0.2',
component: 'jwt-auth-service',
image: 'registry.local/ops/auth:2025.10.0',
},
summary: 'jsonwebtoken <10.0.0 allows algorithm downgrade.',
severity: 'high',
cvss: 8.1,
kev: true,
policyBadge: 'fail',
vex: {
statementId: 'vex:tenant-default:jwt-auth:5d1a',
state: 'under_investigation',
justification: 'Advisory AI flagged reachable path via Scheduler run 42.',
},
reachability: {
status: 'reachable',
lastObserved: '2025-11-07T23:11:04Z',
signalsVersion: 'signals-2025.310.1',
},
evidence: {
sbomDigest: 'sha256:6c81a92f',
policyRunId: 'policy-run::2025-11-07::ca9f',
attestationId: 'dsse://authority/attest/84a2',
},
timestamps: {
firstSeen: '2025-10-31T04:22:18Z',
lastSeen: '2025-11-07T23:16:51Z',
},
},
{
findingId: 'tenant-default:advisory-ai:sha256:9bf4',
coordinates: {
advisoryId: 'CVE-2024-67890',
package: 'pkg:npm/lodash@4.17.20',
component: 'data-transform',
image: 'registry.local/ops/transform:2025.10.0',
},
summary: 'lodash prototype pollution in _.set and related functions.',
severity: 'critical',
cvss: 9.1,
kev: false,
policyBadge: 'fail',
vex: {
statementId: 'vex:tenant-default:data-transform:9bf4',
state: 'affected',
justification: 'Confirmed vulnerable path in production.',
},
reachability: {
status: 'reachable',
lastObserved: '2025-11-08T10:30:00Z',
signalsVersion: 'signals-2025.310.1',
},
timestamps: {
firstSeen: '2025-10-15T08:00:00Z',
lastSeen: '2025-11-08T10:30:00Z',
},
},
{
findingId: 'tenant-default:advisory-ai:sha256:abc1',
coordinates: {
advisoryId: 'CVE-2024-11111',
package: 'pkg:npm/express@4.18.1',
component: 'api-gateway',
image: 'registry.local/ops/gateway:2025.10.0',
},
summary: 'Express.js path traversal vulnerability.',
severity: 'medium',
cvss: 5.3,
kev: false,
policyBadge: 'warn',
vex: {
statementId: 'vex:tenant-default:api-gateway:abc1',
state: 'not_affected',
justification: 'Mitigation applied via WAF rules.',
},
reachability: {
status: 'unreachable',
lastObserved: '2025-11-06T14:00:00Z',
signalsVersion: 'signals-2025.310.1',
},
timestamps: {
firstSeen: '2025-09-20T12:00:00Z',
lastSeen: '2025-11-06T14:00:00Z',
},
},
];
listFindings(options: VulnFindingsQueryOptions = {}): Observable<VulnFindingsResponse> {
const traceId = options.traceId ?? generateTraceId();
let filtered = [...this.mockFindings];
// Apply filters
if (options.severity?.length) {
filtered = filtered.filter((f) => options.severity!.includes(f.severity));
}
if (options.policyBadge?.length) {
filtered = filtered.filter((f) => options.policyBadge!.includes(f.policyBadge));
}
if (options.reachability?.length) {
filtered = filtered.filter((f) => f.reachability && options.reachability!.includes(f.reachability.status));
}
if (options.vexState?.length) {
filtered = filtered.filter((f) => f.vex && options.vexState!.includes(f.vex.state));
}
if (options.search) {
const searchLower = options.search.toLowerCase();
filtered = filtered.filter((f) =>
f.coordinates.advisoryId.toLowerCase().includes(searchLower) ||
f.summary.toLowerCase().includes(searchLower)
);
}
// Sort: severity desc, cvss desc, findingId asc
const severityOrder: Record<VulnSeverity, number> = {
critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0,
};
filtered.sort((a, b) => {
const sevDiff = severityOrder[b.severity] - severityOrder[a.severity];
if (sevDiff !== 0) return sevDiff;
const cvssDiff = (b.cvss ?? 0) - (a.cvss ?? 0);
if (cvssDiff !== 0) return cvssDiff;
return a.findingId.localeCompare(b.findingId);
});
// Paginate
const pageSize = options.pageSize ?? 50;
const items = filtered.slice(0, pageSize);
const response: VulnFindingsResponse = {
items,
facets: this.computeFacets(this.mockFindings),
nextPageToken: filtered.length > pageSize ? 'mock-next-page' : null,
total: filtered.length,
traceId,
};
return of(response).pipe(delay(50));
}
getFacets(options: VulnFindingsQueryOptions = {}): Observable<VulnFacets> {
return of(this.computeFacets(this.mockFindings)).pipe(delay(25));
}
getFinding(findingId: string, options: VulnFindingQueryOptions = {}): Observable<VulnFindingDetail> {
const traceId = options.traceId ?? generateTraceId();
const finding = this.mockFindings.find((f) => f.findingId === findingId);
if (!finding) {
return throwError(() => new Error(`Finding ${findingId} not found`));
}
const detail: VulnFindingDetail = {
findingId: finding.findingId,
details: {
description: finding.summary,
references: [
`https://nvd.nist.gov/vuln/detail/${finding.coordinates.advisoryId}`,
'https://github.com/security/advisories',
],
exploitAvailability: finding.kev ? 'known_exploit' : 'unknown',
},
policyBadges: [
{
policyId: 'policy://tenant-default/runtime-hardening',
verdict: finding.policyBadge,
explainUrl: `/policy/runs/${finding.evidence?.policyRunId ?? 'unknown'}`,
},
],
vex: finding.vex ? {
statementId: finding.vex.statementId,
state: finding.vex.state,
justification: finding.vex.justification,
impactStatement: 'Service remains exposed until patch applied.',
remediations: [
{
type: 'patch',
description: `Upgrade ${finding.coordinates.package} to latest version.`,
deadline: '2025-12-15T00:00:00Z',
},
],
} : undefined,
reachability: finding.reachability ? {
status: finding.reachability.status,
callPathSamples: ['api-gateway -> service -> vulnerable-function'],
lastUpdated: finding.reachability.lastObserved,
} : undefined,
evidence: {
sbom: finding.evidence?.sbomDigest ? {
digest: finding.evidence.sbomDigest,
componentPath: ['/package.json', '/node_modules/' + finding.coordinates.package.split('@')[0].replace('pkg:npm/', '')],
} : undefined,
attestations: finding.evidence?.attestationId ? [
{
type: 'scan-report',
attestationId: finding.evidence.attestationId,
signer: 'attestor@stella-ops.org',
bundleDigest: 'sha256:e2bb1234',
},
] : undefined,
},
timestamps: finding.timestamps ? {
firstSeen: finding.timestamps.firstSeen,
lastSeen: finding.timestamps.lastSeen,
vexLastUpdated: '2025-11-07T23:10:09Z',
} : undefined,
traceId,
etag: `"finding-${findingId}-${Date.now()}"`,
};
return of(detail).pipe(delay(30));
}
createTicket(request: VulnTicketRequest, options: VulnFindingQueryOptions = {}): Observable<VulnTicketResponse> {
const traceId = options.traceId ?? generateTraceId();
const ticketId = `console-ticket::${request.tenant}::${new Date().toISOString().split('T')[0]}::${String(Date.now()).slice(-5)}`;
const response: VulnTicketResponse = {
ticketId,
payload: {
version: '2025-12-01',
tenant: request.tenant,
findings: request.selection.map((id) => {
const finding = this.mockFindings.find((f) => f.findingId === id);
return {
findingId: id,
severity: finding?.severity ?? 'unknown',
};
}),
policyBadge: 'fail',
vexSummary: `${request.selection.length} findings pending review.`,
attachments: [
{
type: 'json',
name: `console-ticket-${ticketId}.json`,
digest: 'sha256:mock1234',
contentType: 'application/json',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
],
},
auditEventId: `console.ticket.export::${ticketId}`,
traceId,
};
return of(response).pipe(delay(100));
}
private computeFacets(findings: VulnFinding[]): VulnFacets {
const severityCounts: Record<string, number> = {};
const policyBadgeCounts: Record<string, number> = {};
const reachabilityCounts: Record<string, number> = {};
const vexStateCounts: Record<string, number> = {};
for (const f of findings) {
severityCounts[f.severity] = (severityCounts[f.severity] ?? 0) + 1;
policyBadgeCounts[f.policyBadge] = (policyBadgeCounts[f.policyBadge] ?? 0) + 1;
if (f.reachability) {
reachabilityCounts[f.reachability.status] = (reachabilityCounts[f.reachability.status] ?? 0) + 1;
}
if (f.vex) {
vexStateCounts[f.vex.state] = (vexStateCounts[f.vex.state] ?? 0) + 1;
}
}
return {
severity: Object.entries(severityCounts).map(([value, count]) => ({ value, count })),
policyBadge: Object.entries(policyBadgeCounts).map(([value, count]) => ({ value, count })),
reachability: Object.entries(reachabilityCounts).map(([value, count]) => ({ value, count })),
vexState: Object.entries(vexStateCounts).map(([value, count]) => ({ value, count })),
};
}
}

View File

@@ -0,0 +1,232 @@
/**
* Console Vuln Workspace Models.
* Implements CONSOLE-VULN-29-001.
*/
/** Severity levels. */
export type VulnSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
/** Policy verdict badges. */
export type PolicyBadge = 'pass' | 'warn' | 'fail' | 'waived';
/** VEX state values. */
export type VexState =
| 'not_affected'
| 'fixed'
| 'under_investigation'
| 'affected'
| 'unknown'
| 'unavailable';
/** Reachability status. */
export type ReachabilityStatus = 'reachable' | 'unreachable' | 'unknown';
/** Finding coordinates. */
export interface FindingCoordinates {
readonly advisoryId: string;
readonly package: string;
readonly component?: string;
readonly image?: string;
}
/** VEX summary in finding. */
export interface FindingVex {
readonly statementId: string;
readonly state: VexState;
readonly justification?: string;
}
/** Reachability info in finding. */
export interface FindingReachability {
readonly status: ReachabilityStatus;
readonly lastObserved?: string;
readonly signalsVersion?: string;
}
/** Evidence links in finding. */
export interface FindingEvidence {
readonly sbomDigest?: string;
readonly policyRunId?: string;
readonly attestationId?: string;
}
/** Finding timestamps. */
export interface FindingTimestamps {
readonly firstSeen: string;
readonly lastSeen: string;
}
/** Vulnerability finding item. */
export interface VulnFinding {
readonly findingId: string;
readonly coordinates: FindingCoordinates;
readonly summary: string;
readonly severity: VulnSeverity;
readonly cvss?: number;
readonly kev?: boolean;
readonly policyBadge: PolicyBadge;
readonly vex?: FindingVex;
readonly reachability?: FindingReachability;
readonly evidence?: FindingEvidence;
readonly timestamps?: FindingTimestamps;
}
/** Facet value with count. */
export interface FacetValue {
readonly value: string;
readonly count: number;
}
/** Facets for sidebar filters. */
export interface VulnFacets {
readonly severity?: readonly FacetValue[];
readonly policyBadge?: readonly FacetValue[];
readonly reachability?: readonly FacetValue[];
readonly vexState?: readonly FacetValue[];
readonly product?: readonly FacetValue[];
}
/** Paginated findings response. */
export interface VulnFindingsResponse {
readonly items: readonly VulnFinding[];
readonly facets?: VulnFacets;
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Query options for findings. */
export interface VulnFindingsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly severity?: readonly VulnSeverity[];
readonly product?: readonly string[];
readonly policyBadge?: readonly PolicyBadge[];
readonly vexState?: readonly VexState[];
readonly reachability?: readonly ReachabilityStatus[];
readonly search?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Policy badge detail. */
export interface PolicyBadgeDetail {
readonly policyId: string;
readonly verdict: PolicyBadge;
readonly explainUrl?: string;
}
/** Remediation entry. */
export interface Remediation {
readonly type: string;
readonly description: string;
readonly deadline?: string;
}
/** Full VEX info for detail view. */
export interface FindingVexDetail {
readonly statementId: string;
readonly state: VexState;
readonly justification?: string;
readonly impactStatement?: string;
readonly remediations?: readonly Remediation[];
}
/** Reachability detail. */
export interface FindingReachabilityDetail {
readonly status: ReachabilityStatus;
readonly callPathSamples?: readonly string[];
readonly lastUpdated?: string;
}
/** SBOM evidence. */
export interface SbomEvidence {
readonly digest: string;
readonly componentPath?: readonly string[];
}
/** Attestation entry. */
export interface AttestationEvidence {
readonly type: string;
readonly attestationId: string;
readonly signer?: string;
readonly bundleDigest?: string;
}
/** Full evidence for detail view. */
export interface FindingEvidenceDetail {
readonly sbom?: SbomEvidence;
readonly attestations?: readonly AttestationEvidence[];
}
/** Finding details payload. */
export interface FindingDetails {
readonly description?: string;
readonly references?: readonly string[];
readonly exploitAvailability?: string;
}
/** Finding timestamps for detail view. */
export interface FindingTimestampsDetail {
readonly firstSeen: string;
readonly lastSeen: string;
readonly vexLastUpdated?: string;
}
/** Full finding detail response. */
export interface VulnFindingDetail {
readonly findingId: string;
readonly details?: FindingDetails;
readonly policyBadges?: readonly PolicyBadgeDetail[];
readonly vex?: FindingVexDetail;
readonly reachability?: FindingReachabilityDetail;
readonly evidence?: FindingEvidenceDetail;
readonly timestamps?: FindingTimestampsDetail;
readonly traceId?: string;
readonly etag?: string;
}
/** Query options for finding detail. */
export interface VulnFindingQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Ticket export request. */
export interface VulnTicketRequest {
readonly tenant: string;
readonly selection: readonly string[];
readonly targetSystem: string;
readonly metadata?: Record<string, unknown>;
}
/** Ticket attachment. */
export interface TicketAttachment {
readonly type: string;
readonly name: string;
readonly digest: string;
readonly contentType: string;
readonly expiresAt?: string;
}
/** Ticket payload. */
export interface TicketPayload {
readonly version: string;
readonly tenant: string;
readonly findings: readonly { findingId: string; severity: string }[];
readonly policyBadge?: string;
readonly vexSummary?: string;
readonly attachments?: readonly TicketAttachment[];
}
/** Ticket response. */
export interface VulnTicketResponse {
readonly ticketId: string;
readonly payload: TicketPayload;
readonly auditEventId: string;
readonly traceId?: string;
}

View File

@@ -0,0 +1,369 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
EVENT_SOURCE_FACTORY,
EventSourceFactory,
DEFAULT_EVENT_SOURCE_FACTORY,
} from './console-status.client';
import {
ExportProfile,
ExportProfilesResponse,
ExportProfilesQueryOptions,
ExportRunRequest,
ExportRunResponse,
ExportRunQueryOptions,
ExportRunEvent,
DistributionResponse,
ExportRunStatus,
ExportTargetType,
ExportFormat,
} from './export-center.models';
import { generateTraceId } from './trace.util';
export const EXPORT_CENTER_API_BASE_URL = new InjectionToken<string>('EXPORT_CENTER_API_BASE_URL');
/**
* Export Center API interface.
* Implements WEB-EXPORT-35-001, WEB-EXPORT-36-001, WEB-EXPORT-37-001.
*/
export interface ExportCenterApi {
/** List export profiles. */
listProfiles(options?: ExportProfilesQueryOptions): Observable<ExportProfilesResponse>;
/** Start an export run. */
startRun(request: ExportRunRequest, options?: ExportRunQueryOptions): Observable<ExportRunResponse>;
/** Get export run status. */
getRun(runId: string, options?: ExportRunQueryOptions): Observable<ExportRunResponse>;
/** Stream export run events (SSE). */
streamRun(runId: string, options?: ExportRunQueryOptions): Observable<ExportRunEvent>;
/** Get distribution signed URLs. */
getDistribution(distributionId: string, options?: ExportRunQueryOptions): Observable<DistributionResponse>;
}
export const EXPORT_CENTER_API = new InjectionToken<ExportCenterApi>('EXPORT_CENTER_API');
/**
* HTTP Export Center Client.
* Implements WEB-EXPORT-35-001, WEB-EXPORT-36-001, WEB-EXPORT-37-001.
*/
@Injectable({ providedIn: 'root' })
export class ExportCenterHttpClient implements ExportCenterApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(EXPORT_CENTER_API_BASE_URL) private readonly baseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory = DEFAULT_EVENT_SOURCE_FACTORY
) {}
listProfiles(options: ExportProfilesQueryOptions = {}): Observable<ExportProfilesResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.pageToken) {
params = params.set('pageToken', options.pageToken);
}
if (options.pageSize) {
params = params.set('pageSize', String(options.pageSize));
}
return this.http.get<ExportProfilesResponse>(`${this.baseUrl}/profiles`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
startRun(request: ExportRunRequest, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('export', 'write', ['export:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing export:write scope'));
}
let headers = this.buildHeaders(options);
if (options.idempotencyKey) {
headers = headers.set('Idempotency-Key', options.idempotencyKey);
}
return this.http.post<ExportRunResponse>(`${this.baseUrl}/runs`, request, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<ExportRunResponse>(
`${this.baseUrl}/runs/${encodeURIComponent(runId)}`,
{ headers }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
streamRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunEvent> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
const url = `${this.baseUrl}/runs/${encodeURIComponent(runId)}/events?tenant=${encodeURIComponent(tenant)}&traceId=${encodeURIComponent(traceId)}`;
return new Observable<ExportRunEvent>((observer) => {
const source = this.eventSourceFactory(url);
const handleEvent = (eventType: string) => (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
observer.next({
event: eventType as ExportRunEvent['event'],
runId,
...data,
traceId,
});
} catch {
// Skip invalid JSON
}
};
source.addEventListener('started', handleEvent('started'));
source.addEventListener('progress', handleEvent('progress'));
source.addEventListener('artifact_ready', handleEvent('artifact_ready'));
source.addEventListener('completed', handleEvent('completed'));
source.addEventListener('failed', handleEvent('failed'));
source.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as ExportRunEvent;
observer.next({ ...parsed, runId, traceId });
} catch {
// Ignore parse errors
}
};
source.onerror = () => {
observer.error(new Error(`[${traceId}] Export run stream error`));
source.close();
};
return () => source.close();
});
}
getDistribution(distributionId: string, options: ExportRunQueryOptions = {}): Observable<DistributionResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('export', 'read', ['export:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing export:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<DistributionResponse>(
`${this.baseUrl}/distributions/${encodeURIComponent(distributionId)}`,
{ headers }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(opts: { tenantId?: string; traceId?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('ExportCenterClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Export Center error: ${err.message}`);
}
return new Error(`[${traceId}] Export Center error: Unknown error`);
}
}
/**
* Mock Export Center API for quickstart mode.
*/
@Injectable({ providedIn: 'root' })
export class MockExportCenterClient implements ExportCenterApi {
private readonly mockProfiles: ExportProfile[] = [
{
profileId: 'export-profile::tenant-default::daily-vex',
name: 'Daily VEX Export',
description: 'Daily export of VEX statements and advisories',
targets: ['vex', 'advisory'],
formats: ['json', 'ndjson'],
schedule: '0 2 * * *',
retentionDays: 30,
createdAt: '2025-10-01T00:00:00Z',
updatedAt: '2025-11-15T10:00:00Z',
},
{
profileId: 'export-profile::tenant-default::weekly-full',
name: 'Weekly Full Export',
description: 'Weekly comprehensive export of all security data',
targets: ['vex', 'advisory', 'policy', 'scan', 'sbom'],
formats: ['json', 'ndjson', 'csv'],
schedule: '0 3 * * 0',
retentionDays: 90,
createdAt: '2025-09-15T00:00:00Z',
},
];
private runCounter = 0;
listProfiles(options: ExportProfilesQueryOptions = {}): Observable<ExportProfilesResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: this.mockProfiles,
total: this.mockProfiles.length,
traceId,
}).pipe(delay(50));
}
startRun(request: ExportRunRequest, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
const traceId = options.traceId ?? generateTraceId();
this.runCounter++;
const runId = `export-run::tenant-default::${new Date().toISOString().split('T')[0]}::${String(this.runCounter).padStart(4, '0')}`;
return of({
runId,
status: 'queued' as ExportRunStatus,
profileId: request.profileId,
estimateSeconds: 420,
links: {
status: `/export-center/runs/${runId}`,
events: `/export-center/runs/${runId}/events`,
},
retryAfter: 5,
traceId,
}).pipe(delay(100));
}
getRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
runId,
status: 'running' as ExportRunStatus,
startedAt: new Date(Date.now() - 60000).toISOString(),
outputs: [
{
type: 'manifest',
format: 'json' as ExportFormat,
url: `https://exports.local/tenant-default/${runId}/manifest.json?sig=mock`,
sha256: 'sha256:c0ffee1234567890',
dsseUrl: `https://exports.local/tenant-default/${runId}/manifest.dsse?sig=mock`,
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(),
},
],
progress: {
percent: 35,
itemsCompleted: 70,
itemsTotal: 200,
},
errors: [],
traceId,
}).pipe(delay(50));
}
streamRun(runId: string, options: ExportRunQueryOptions = {}): Observable<ExportRunEvent> {
const traceId = options.traceId ?? generateTraceId();
return new Observable<ExportRunEvent>((observer) => {
// Emit started
setTimeout(() => {
observer.next({
event: 'started',
runId,
status: 'running',
traceId,
});
}, 100);
// Emit progress updates
let percent = 0;
const progressInterval = setInterval(() => {
percent += 10;
if (percent <= 100) {
observer.next({
event: 'progress',
runId,
percent,
itemsCompleted: percent * 2,
itemsTotal: 200,
traceId,
});
}
if (percent >= 100) {
clearInterval(progressInterval);
// Emit completed
observer.next({
event: 'completed',
runId,
status: 'succeeded',
manifestUrl: `https://exports.local/tenant-default/${runId}/manifest.json?sig=mock`,
manifestDsseUrl: `https://exports.local/tenant-default/${runId}/manifest.dsse?sig=mock`,
traceId,
});
observer.complete();
}
}, 500);
return () => clearInterval(progressInterval);
});
}
getDistribution(distributionId: string, options: ExportRunQueryOptions = {}): Observable<DistributionResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
distributionId,
type: 'oci' as const,
ref: 'registry.local/exports/daily:latest',
url: `https://registry.local/v2/exports/daily/manifests/latest?sig=mock`,
sha256: 'sha256:dist1234567890',
dsseUrl: `https://registry.local/v2/exports/daily/manifests/latest.dsse?sig=mock`,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
size: 1024 * 1024 * 50,
traceId,
etag: `"dist-${distributionId}-${Date.now()}"`,
}).pipe(delay(30));
}
}

View File

@@ -0,0 +1,186 @@
/**
* Export Center Models.
* Implements WEB-EXPORT-35-001, WEB-EXPORT-36-001, WEB-EXPORT-37-001.
*/
/** Export run status. */
export type ExportRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'expired';
/** Export format. */
export type ExportFormat = 'json' | 'ndjson' | 'csv' | 'pdf';
/** Export target type. */
export type ExportTargetType = 'vex' | 'advisory' | 'policy' | 'scan' | 'sbom' | 'attestation';
/** Export priority. */
export type ExportPriority = 'low' | 'normal' | 'high';
/** Distribution type. */
export type DistributionType = 'oci' | 'object-storage' | 's3' | 'gcs' | 'azure-blob';
/** Export profile. */
export interface ExportProfile {
readonly profileId: string;
readonly name: string;
readonly description?: string;
readonly targets: readonly ExportTargetType[];
readonly formats: readonly ExportFormat[];
readonly schedule?: string;
readonly retentionDays?: number;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Export profiles list response. */
export interface ExportProfilesResponse {
readonly items: readonly ExportProfile[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Distribution signing config. */
export interface DistributionSigning {
readonly enabled: boolean;
readonly keyRef?: string;
}
/** Distribution config. */
export interface DistributionConfig {
readonly type: DistributionType;
readonly ref?: string;
readonly signing?: DistributionSigning;
}
/** Encryption config. */
export interface EncryptionConfig {
readonly enabled: boolean;
readonly kmsKey?: string;
}
/** Export run request. */
export interface ExportRunRequest {
readonly profileId?: string;
readonly targets: readonly ExportTargetType[];
readonly formats: readonly ExportFormat[];
readonly distribution?: DistributionConfig;
readonly retentionDays?: number;
readonly encryption?: EncryptionConfig;
readonly priority?: ExportPriority;
}
/** Export run links. */
export interface ExportRunLinks {
readonly status: string;
readonly events?: string;
}
/** Export run output. */
export interface ExportRunOutput {
readonly type: string;
readonly format: ExportFormat | string;
readonly url: string;
readonly sha256?: string;
readonly dsseUrl?: string;
readonly expiresAt?: string;
readonly size?: number;
}
/** Export run progress. */
export interface ExportRunProgress {
readonly percent: number;
readonly itemsCompleted?: number;
readonly itemsTotal?: number;
}
/** Export run error. */
export interface ExportRunError {
readonly code: string;
readonly message: string;
readonly field?: string;
}
/** Export run response. */
export interface ExportRunResponse {
readonly runId: string;
readonly status: ExportRunStatus;
readonly profileId?: string;
readonly startedAt?: string;
readonly completedAt?: string;
readonly estimateSeconds?: number;
readonly links?: ExportRunLinks;
readonly outputs?: readonly ExportRunOutput[];
readonly progress?: ExportRunProgress;
readonly errors?: readonly ExportRunError[];
readonly retryAfter?: number;
readonly traceId?: string;
}
/** Export SSE event types. */
export type ExportEventType =
| 'started'
| 'progress'
| 'artifact_ready'
| 'completed'
| 'failed';
/** Export SSE event. */
export interface ExportRunEvent {
readonly event: ExportEventType;
readonly runId: string;
readonly status?: ExportRunStatus;
readonly percent?: number;
readonly itemsCompleted?: number;
readonly itemsTotal?: number;
readonly type?: string;
readonly id?: string;
readonly url?: string;
readonly sha256?: string;
readonly format?: string;
readonly manifestUrl?: string;
readonly manifestDsseUrl?: string;
readonly code?: string;
readonly message?: string;
readonly retryAfterSeconds?: number;
readonly traceId?: string;
}
/** Distribution response. */
export interface DistributionResponse {
readonly distributionId: string;
readonly type: DistributionType;
readonly ref?: string;
readonly url: string;
readonly sha256?: string;
readonly dsseUrl?: string;
readonly expiresAt: string;
readonly size?: number;
readonly traceId?: string;
readonly etag?: string;
}
/** Export profile query options. */
export interface ExportProfilesQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly traceId?: string;
}
/** Export run query options. */
export interface ExportRunQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly idempotencyKey?: string;
readonly traceId?: string;
}
/** Export error codes. */
export type ExportErrorCode =
| 'ERR_EXPORT_PROFILE_NOT_FOUND'
| 'ERR_EXPORT_REQUEST_INVALID'
| 'ERR_EXPORT_TOO_LARGE'
| 'ERR_EXPORT_RATE_LIMIT'
| 'ERR_EXPORT_DISTRIBUTION_FAILED'
| 'ERR_EXPORT_EXPIRED';

View File

@@ -0,0 +1,461 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
ObsHealthResponse,
ObsSloResponse,
TraceResponse,
LogsResponse,
LogsQueryOptions,
EvidenceResponse,
AttestationsResponse,
IncidentModeResponse,
IncidentModeRequest,
SealStatusResponse,
ObsQueryOptions,
} from './gateway-observability.models';
import { generateTraceId } from './trace.util';
export const OBS_API_BASE_URL = new InjectionToken<string>('OBS_API_BASE_URL');
/**
* Gateway Observability API interface.
* Implements WEB-OBS-50-001 through WEB-OBS-56-001.
*/
export interface GatewayObservabilityApi {
/** Get health status. WEB-OBS-51-001. */
getHealth(options?: ObsQueryOptions): Observable<ObsHealthResponse>;
/** Get SLO metrics. WEB-OBS-51-001. */
getSlos(options?: ObsQueryOptions): Observable<ObsSloResponse>;
/** Get trace by ID. WEB-OBS-52-001. */
getTrace(traceId: string, options?: ObsQueryOptions): Observable<TraceResponse>;
/** Query logs. WEB-OBS-52-001. */
queryLogs(query: LogsQueryOptions): Observable<LogsResponse>;
/** List evidence. WEB-OBS-54-001. */
listEvidence(options?: ObsQueryOptions): Observable<EvidenceResponse>;
/** List attestations. WEB-OBS-54-001. */
listAttestations(options?: ObsQueryOptions): Observable<AttestationsResponse>;
/** Get incident mode status. WEB-OBS-55-001. */
getIncidentMode(options?: ObsQueryOptions): Observable<IncidentModeResponse>;
/** Update incident mode. WEB-OBS-55-001. */
updateIncidentMode(request: IncidentModeRequest, options?: ObsQueryOptions): Observable<IncidentModeResponse>;
/** Get seal status. WEB-OBS-56-001. */
getSealStatus(options?: ObsQueryOptions): Observable<SealStatusResponse>;
}
export const GATEWAY_OBS_API = new InjectionToken<GatewayObservabilityApi>('GATEWAY_OBS_API');
/**
* HTTP Gateway Observability Client.
* Implements WEB-OBS-50-001 through WEB-OBS-56-001.
*/
@Injectable({ providedIn: 'root' })
export class GatewayObservabilityHttpClient implements GatewayObservabilityApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(OBS_API_BASE_URL) private readonly baseUrl: string
) {}
getHealth(options: ObsQueryOptions = {}): Observable<ObsHealthResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<ObsHealthResponse>(`${this.baseUrl}/obs/health`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getSlos(options: ObsQueryOptions = {}): Observable<ObsSloResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<ObsSloResponse>(`${this.baseUrl}/obs/slo`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getTrace(traceIdParam: string, options: ObsQueryOptions = {}): Observable<TraceResponse> {
const reqTraceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('obs', 'read', ['timeline:read'], options.projectId, reqTraceId)) {
return throwError(() => new Error('Unauthorized: missing timeline:read scope'));
}
const headers = this.buildHeaders(reqTraceId);
return this.http.get<TraceResponse>(
`${this.baseUrl}/obs/trace/${encodeURIComponent(traceIdParam)}`,
{ headers }
).pipe(
catchError((err) => throwError(() => this.mapError(err, reqTraceId)))
);
}
queryLogs(query: LogsQueryOptions): Observable<LogsResponse> {
const traceId = query.traceId ?? generateTraceId();
if (!this.tenantService.authorize('obs', 'read', ['timeline:read'], query.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing timeline:read scope'));
}
const headers = this.buildHeaders(traceId);
let params = new HttpParams();
if (query.service) params = params.set('service', query.service);
if (query.level) params = params.set('level', query.level);
if (query.traceId) params = params.set('traceId', query.traceId);
if (query.startTime) params = params.set('startTime', query.startTime);
if (query.endTime) params = params.set('endTime', query.endTime);
if (query.limit) params = params.set('limit', String(query.limit));
if (query.pageToken) params = params.set('pageToken', query.pageToken);
return this.http.get<LogsResponse>(`${this.baseUrl}/obs/logs`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
listEvidence(options: ObsQueryOptions = {}): Observable<EvidenceResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('obs', 'read', ['evidence:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing evidence:read scope'));
}
const headers = this.buildHeaders(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<EvidenceResponse>(`${this.baseUrl}/evidence`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
listAttestations(options: ObsQueryOptions = {}): Observable<AttestationsResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('obs', 'read', ['attest:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing attest:read scope'));
}
const headers = this.buildHeaders(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<AttestationsResponse>(`${this.baseUrl}/attestations`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getIncidentMode(options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<IncidentModeResponse>(`${this.baseUrl}/obs/incident-mode`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
updateIncidentMode(request: IncidentModeRequest, options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.post<IncidentModeResponse>(`${this.baseUrl}/obs/incident-mode`, request, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getSealStatus(options: ObsQueryOptions = {}): Observable<SealStatusResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<SealStatusResponse>(`${this.baseUrl}/obs/seal-status`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(traceId: string): HttpHeaders {
const tenant = this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
Accept: 'application/json',
});
}
private buildPaginationParams(options: ObsQueryOptions): HttpParams {
let params = new HttpParams();
if (options.pageToken) {
params = params.set('pageToken', options.pageToken);
}
if (options.pageSize) {
params = params.set('pageSize', String(options.pageSize));
}
return params;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Observability error: ${err.message}`);
}
return new Error(`[${traceId}] Observability error: Unknown error`);
}
}
/**
* Mock Gateway Observability Client for quickstart mode.
*/
@Injectable({ providedIn: 'root' })
export class MockGatewayObservabilityClient implements GatewayObservabilityApi {
getHealth(options: ObsQueryOptions = {}): Observable<ObsHealthResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
status: 'healthy' as const,
checks: [
{ name: 'database', status: 'healthy' as const, latencyMs: 5, checkedAt: new Date().toISOString() },
{ name: 'cache', status: 'healthy' as const, latencyMs: 2, checkedAt: new Date().toISOString() },
{ name: 'queue', status: 'healthy' as const, latencyMs: 8, checkedAt: new Date().toISOString() },
],
uptimeSeconds: 86400,
timestamp: new Date().toISOString(),
traceId,
}).pipe(delay(50));
}
getSlos(options: ObsQueryOptions = {}): Observable<ObsSloResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
slos: [
{
name: 'Availability',
target: 99.9,
current: 99.95,
status: 'met' as const,
burnRate: 0.5,
errorBudgetRemaining: 0.05,
windowHours: 720,
},
{
name: 'Latency P99',
target: 200,
current: 180,
status: 'met' as const,
burnRate: 0.9,
errorBudgetRemaining: 0.1,
windowHours: 720,
},
{
name: 'Error Rate',
target: 0.1,
current: 0.08,
status: 'met' as const,
burnRate: 0.8,
errorBudgetRemaining: 0.02,
windowHours: 720,
},
],
exemplars: [
{ traceId: 'trace-001', timestamp: new Date().toISOString(), value: 150, labels: { endpoint: '/api/v1/vulns' } },
],
calculatedAt: new Date().toISOString(),
traceId,
}).pipe(delay(100));
}
getTrace(traceIdParam: string, options: ObsQueryOptions = {}): Observable<TraceResponse> {
return of({
traceId: traceIdParam,
spans: [
{
spanId: 'span-001',
operationName: 'HTTP GET /api/v1/vulns',
serviceName: 'gateway',
startTime: new Date(Date.now() - 200).toISOString(),
endTime: new Date().toISOString(),
durationMs: 200,
status: 'ok' as const,
attributes: { 'http.method': 'GET', 'http.status_code': 200 },
},
{
spanId: 'span-002',
parentSpanId: 'span-001',
operationName: 'DB query',
serviceName: 'concelier',
startTime: new Date(Date.now() - 150).toISOString(),
endTime: new Date(Date.now() - 50).toISOString(),
durationMs: 100,
status: 'ok' as const,
},
],
services: ['gateway', 'concelier'],
duration: 200,
timestamp: new Date().toISOString(),
}).pipe(delay(80));
}
queryLogs(query: LogsQueryOptions): Observable<LogsResponse> {
const traceId = query.traceId ?? generateTraceId();
return of({
items: [
{
timestamp: new Date().toISOString(),
level: 'info' as const,
message: 'Request processed successfully',
service: 'gateway',
traceId: 'trace-001',
},
{
timestamp: new Date(Date.now() - 1000).toISOString(),
level: 'debug' as const,
message: 'Cache hit for advisory lookup',
service: 'concelier',
},
],
total: 2,
traceId,
}).pipe(delay(60));
}
listEvidence(options: ObsQueryOptions = {}): Observable<EvidenceResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [
{
evidenceId: 'ev-001',
type: 'scan' as const,
subjectDigest: 'sha256:abc123',
subjectName: 'myapp:latest',
createdAt: new Date().toISOString(),
provenance: {
builderName: 'scanner-v1',
buildId: 'build-001',
timestamp: new Date().toISOString(),
},
},
{
evidenceId: 'ev-002',
type: 'attestation' as const,
subjectDigest: 'sha256:abc123',
subjectName: 'myapp:latest',
createdAt: new Date().toISOString(),
},
],
total: 2,
traceId,
}).pipe(delay(50));
}
listAttestations(options: ObsQueryOptions = {}): Observable<AttestationsResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [
{
attestationId: 'att-001',
predicateType: 'https://slsa.dev/provenance/v1',
subjectDigest: 'sha256:abc123',
subjectName: 'myapp:latest',
issuer: 'stellaops-attestor',
issuedAt: new Date().toISOString(),
verified: true,
verificationSummary: {
result: 'passed' as const,
},
},
],
total: 1,
traceId,
}).pipe(delay(50));
}
getIncidentMode(options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
config: {
status: 'inactive' as const,
},
auditTrail: [
{
action: 'deactivated' as const,
actor: 'admin@example.com',
timestamp: new Date(Date.now() - 86400000).toISOString(),
details: 'Incident resolved',
},
],
traceId,
}).pipe(delay(40));
}
updateIncidentMode(request: IncidentModeRequest, options: ObsQueryOptions = {}): Observable<IncidentModeResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
config: {
status: request.action === 'enable' ? 'active' as const : request.action === 'schedule' ? 'scheduled' as const : 'inactive' as const,
activatedAt: request.action === 'enable' ? new Date().toISOString() : undefined,
activatedBy: 'user@example.com',
samplingOverride: request.samplingOverride,
retentionBumpDays: request.retentionBumpDays,
reason: request.reason,
},
auditTrail: [
{
action: request.action === 'enable' ? 'activated' as const : request.action === 'schedule' ? 'scheduled' as const : 'deactivated' as const,
actor: 'user@example.com',
timestamp: new Date().toISOString(),
details: request.reason,
},
],
traceId,
}).pipe(delay(100));
}
getSealStatus(options: ObsQueryOptions = {}): Observable<SealStatusResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
status: 'unsealed' as const,
unsealedAt: new Date(Date.now() - 3600000).toISOString(),
driftMetrics: [
{
component: 'scanner-config',
expectedHash: 'sha256:expected123',
actualHash: 'sha256:expected123',
drifted: false,
lastChecked: new Date().toISOString(),
},
{
component: 'policy-bundle',
expectedHash: 'sha256:expected456',
actualHash: 'sha256:expected456',
drifted: false,
lastChecked: new Date().toISOString(),
},
],
widgetData: {
sealedComponents: 0,
driftedComponents: 0,
totalComponents: 2,
lastSealVerification: new Date().toISOString(),
},
traceId,
}).pipe(delay(50));
}
}

View File

@@ -0,0 +1,298 @@
/**
* Gateway Observability Models.
* Implements WEB-OBS-50-001 through WEB-OBS-56-001.
*/
/** Health status. */
export type ObsHealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
/** SLO status. */
export type ObsSloStatus = 'met' | 'at_risk' | 'breached';
/**
* WEB-OBS-50-001: Telemetry core integration.
*/
/** Trace context. */
export interface TraceContext {
readonly traceId: string;
readonly spanId: string;
readonly parentSpanId?: string;
readonly sampled: boolean;
}
/** Telemetry metadata. */
export interface TelemetryMetadata {
readonly tenantId: string;
readonly projectId?: string;
readonly service: string;
readonly operation: string;
readonly durationMs: number;
readonly statusCode?: number;
readonly errorCode?: string;
readonly trace: TraceContext;
}
/**
* WEB-OBS-51-001: Health and SLO aggregations.
*/
/** Health check result. */
export interface HealthCheckResult {
readonly name: string;
readonly status: ObsHealthStatus;
readonly message?: string;
readonly latencyMs?: number;
readonly checkedAt: string;
}
/** Health response. */
export interface ObsHealthResponse {
readonly status: ObsHealthStatus;
readonly checks: readonly HealthCheckResult[];
readonly uptimeSeconds?: number;
readonly timestamp: string;
readonly traceId?: string;
}
/** SLO metric. */
export interface SloMetric {
readonly name: string;
readonly target: number;
readonly current: number;
readonly status: ObsSloStatus;
readonly burnRate?: number;
readonly errorBudgetRemaining?: number;
readonly windowHours: number;
}
/** SLO exemplar. */
export interface SloExemplar {
readonly traceId: string;
readonly timestamp: string;
readonly value: number;
readonly labels?: Record<string, string>;
}
/** SLO response. */
export interface ObsSloResponse {
readonly slos: readonly SloMetric[];
readonly exemplars?: readonly SloExemplar[];
readonly calculatedAt: string;
readonly traceId?: string;
}
/**
* WEB-OBS-52-001: Trace and log proxy.
*/
/** Trace span. */
export interface TraceSpan {
readonly spanId: string;
readonly parentSpanId?: string;
readonly operationName: string;
readonly serviceName: string;
readonly startTime: string;
readonly endTime?: string;
readonly durationMs?: number;
readonly status: 'ok' | 'error' | 'unset';
readonly attributes?: Record<string, unknown>;
readonly events?: readonly SpanEvent[];
}
/** Span event. */
export interface SpanEvent {
readonly name: string;
readonly timestamp: string;
readonly attributes?: Record<string, unknown>;
}
/** Trace response. */
export interface TraceResponse {
readonly traceId: string;
readonly spans: readonly TraceSpan[];
readonly services: readonly string[];
readonly duration?: number;
readonly timestamp: string;
}
/** Log entry. */
export interface LogEntry {
readonly timestamp: string;
readonly level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
readonly message: string;
readonly service?: string;
readonly traceId?: string;
readonly spanId?: string;
readonly attributes?: Record<string, unknown>;
}
/** Logs query options. */
export interface LogsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly service?: string;
readonly level?: LogEntry['level'];
readonly traceId?: string;
readonly startTime?: string;
readonly endTime?: string;
readonly limit?: number;
readonly pageToken?: string;
}
/** Logs response. */
export interface LogsResponse {
readonly items: readonly LogEntry[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly signedUrl?: string;
readonly traceId?: string;
}
/**
* WEB-OBS-54-001: Evidence and attestations.
*/
/** Evidence type. */
export type EvidenceType = 'scan' | 'attestation' | 'signature' | 'policy' | 'vex';
/** Evidence item. */
export interface EvidenceItem {
readonly evidenceId: string;
readonly type: EvidenceType;
readonly subjectDigest: string;
readonly subjectName?: string;
readonly createdAt: string;
readonly expiresAt?: string;
readonly provenance?: {
readonly builderName?: string;
readonly buildId?: string;
readonly timestamp: string;
};
readonly metadata?: Record<string, unknown>;
}
/** Evidence response. */
export interface EvidenceResponse {
readonly items: readonly EvidenceItem[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Attestation. */
export interface Attestation {
readonly attestationId: string;
readonly predicateType: string;
readonly subjectDigest: string;
readonly subjectName?: string;
readonly issuer?: string;
readonly issuedAt: string;
readonly expiresAt?: string;
readonly verified: boolean;
readonly verificationSummary?: {
readonly result: 'passed' | 'failed' | 'skipped';
readonly errors?: readonly string[];
readonly warnings?: readonly string[];
};
readonly metadata?: Record<string, unknown>;
}
/** Attestations response. */
export interface AttestationsResponse {
readonly items: readonly Attestation[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/**
* WEB-OBS-55-001: Incident mode.
*/
/** Incident mode status. */
export type IncidentModeStatus = 'active' | 'inactive' | 'scheduled';
/** Incident mode config. */
export interface IncidentModeConfig {
readonly status: IncidentModeStatus;
readonly activatedAt?: string;
readonly activatedBy?: string;
readonly deactivatedAt?: string;
readonly scheduledAt?: string;
readonly scheduledDuration?: number;
readonly samplingOverride?: number;
readonly retentionBumpDays?: number;
readonly reason?: string;
}
/** Incident mode response. */
export interface IncidentModeResponse {
readonly config: IncidentModeConfig;
readonly auditTrail: readonly {
readonly action: 'activated' | 'deactivated' | 'scheduled' | 'modified';
readonly actor: string;
readonly timestamp: string;
readonly details?: string;
}[];
readonly traceId?: string;
}
/** Incident mode request. */
export interface IncidentModeRequest {
readonly action: 'enable' | 'disable' | 'schedule';
readonly scheduledAt?: string;
readonly scheduledDuration?: number;
readonly samplingOverride?: number;
readonly retentionBumpDays?: number;
readonly reason?: string;
}
/**
* WEB-OBS-56-001: Sealed/unsealed status.
*/
/** Seal status. */
export type SealStatus = 'sealed' | 'unsealed' | 'transitioning';
/** Seal drift. */
export interface SealDrift {
readonly component: string;
readonly expectedHash: string;
readonly actualHash?: string;
readonly drifted: boolean;
readonly lastChecked: string;
}
/** Seal status response. */
export interface SealStatusResponse {
readonly status: SealStatus;
readonly sealedAt?: string;
readonly unsealedAt?: string;
readonly driftMetrics: readonly SealDrift[];
readonly widgetData?: {
readonly sealedComponents: number;
readonly driftedComponents: number;
readonly totalComponents: number;
readonly lastSealVerification: string;
};
readonly traceId?: string;
}
/** Observability query options. */
export interface ObsQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly traceId?: string;
}
/** Observability error codes. */
export type ObsErrorCode =
| 'ERR_OBS_TRACE_NOT_FOUND'
| 'ERR_OBS_LOGS_TIMEOUT'
| 'ERR_OBS_EVIDENCE_NOT_FOUND'
| 'ERR_OBS_ATTESTATION_INVALID'
| 'ERR_OBS_INCIDENT_MODE_CONFLICT'
| 'ERR_OBS_SEAL_OPERATION_FAILED';

View File

@@ -0,0 +1,258 @@
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import {
OpenApiSpecResponse,
GatewayInfo,
GatewayHealthCheck,
DeprecatedRoutesResponse,
IdempotencyResponse,
RateLimitInfo,
OpenApiQueryOptions,
} from './gateway-openapi.models';
import { generateTraceId } from './trace.util';
export const GATEWAY_API_BASE_URL = new InjectionToken<string>('GATEWAY_API_BASE_URL');
/**
* Gateway OpenAPI API interface.
* Implements WEB-OAS-61-001, WEB-OAS-61-002, WEB-OAS-62-001, WEB-OAS-63-001.
*/
export interface GatewayOpenApiApi {
/** Get OpenAPI spec. WEB-OAS-61-001. */
getOpenApiSpec(options?: OpenApiQueryOptions): Observable<OpenApiSpecResponse>;
/** Get gateway info. */
getGatewayInfo(options?: OpenApiQueryOptions): Observable<GatewayInfo>;
/** Get gateway health. */
getGatewayHealth(options?: OpenApiQueryOptions): Observable<GatewayHealthCheck>;
/** Get deprecated routes. WEB-OAS-63-001. */
getDeprecatedRoutes(options?: OpenApiQueryOptions): Observable<DeprecatedRoutesResponse>;
/** Check idempotency key. WEB-OAS-62-001. */
checkIdempotencyKey(key: string, options?: OpenApiQueryOptions): Observable<IdempotencyResponse>;
/** Get rate limit info. WEB-OAS-62-001. */
getRateLimitInfo(options?: OpenApiQueryOptions): Observable<RateLimitInfo>;
}
export const GATEWAY_OPENAPI_API = new InjectionToken<GatewayOpenApiApi>('GATEWAY_OPENAPI_API');
/**
* HTTP Gateway OpenAPI Client.
* Implements WEB-OAS-61-001, WEB-OAS-61-002, WEB-OAS-62-001, WEB-OAS-63-001.
*/
@Injectable({ providedIn: 'root' })
export class GatewayOpenApiHttpClient implements GatewayOpenApiApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
@Inject(GATEWAY_API_BASE_URL) private readonly baseUrl: string
) {}
getOpenApiSpec(options: OpenApiQueryOptions = {}): Observable<OpenApiSpecResponse> {
const traceId = options.traceId ?? generateTraceId();
let headers = this.buildHeaders(traceId);
if (options.ifNoneMatch) {
headers = headers.set('If-None-Match', options.ifNoneMatch);
}
return this.http.get<OpenApiSpecResponse>(
`${this.baseUrl}/.well-known/openapi`,
{ headers, observe: 'response' }
).pipe(
map((response: HttpResponse<OpenApiSpecResponse>) => {
const body = response.body!;
const etag = response.headers.get('ETag') || body.etag;
return { ...body, etag, traceId };
}),
catchError((err) => {
if (err.status === 304) {
return throwError(() => new Error(`[${traceId}] Not Modified`));
}
return throwError(() => this.mapError(err, traceId));
})
);
}
getGatewayInfo(options: OpenApiQueryOptions = {}): Observable<GatewayInfo> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<GatewayInfo>(`${this.baseUrl}/info`, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getGatewayHealth(options: OpenApiQueryOptions = {}): Observable<GatewayHealthCheck> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<GatewayHealthCheck>(`${this.baseUrl}/health`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getDeprecatedRoutes(options: OpenApiQueryOptions = {}): Observable<DeprecatedRoutesResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<DeprecatedRoutesResponse>(`${this.baseUrl}/deprecated-routes`, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
checkIdempotencyKey(key: string, options: OpenApiQueryOptions = {}): Observable<IdempotencyResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<IdempotencyResponse>(
`${this.baseUrl}/idempotency/${encodeURIComponent(key)}`,
{ headers }
).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getRateLimitInfo(options: OpenApiQueryOptions = {}): Observable<RateLimitInfo> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<RateLimitInfo>(`${this.baseUrl}/rate-limit`, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(traceId: string): HttpHeaders {
const tenant = this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
Accept: 'application/json',
});
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Gateway OpenAPI error: ${err.message}`);
}
return new Error(`[${traceId}] Gateway OpenAPI error: Unknown error`);
}
}
/**
* Mock Gateway OpenAPI Client for quickstart mode.
*/
@Injectable({ providedIn: 'root' })
export class MockGatewayOpenApiClient implements GatewayOpenApiApi {
private readonly mockSpec: OpenApiSpecResponse = {
openapi: '3.1.0',
info: {
title: 'StellaOps Gateway API',
version: '1.0.0',
description: 'Gateway API for StellaOps platform',
},
paths: {
'/health': { get: { summary: 'Health check' } },
'/info': { get: { summary: 'Gateway info' } },
'/.well-known/openapi': { get: { summary: 'OpenAPI spec' } },
},
etag: '"spec-v1.0.0-20251211"',
versionInfo: {
specVersion: '1.0.0',
gatewayVersion: '1.0.0',
buildTimestamp: '2025-12-11T00:00:00Z',
gitCommit: 'abc123',
},
};
private readonly mockGatewayInfo: GatewayInfo = {
name: 'StellaOps Gateway',
version: '1.0.0',
environment: 'development',
region: 'local',
features: [
'rate-limiting',
'idempotency',
'cursor-pagination',
'deprecation-headers',
'etag-caching',
],
uptime: 86400,
};
private readonly mockDeprecatedRoutes: DeprecatedRoutesResponse = {
items: [
{
path: '/api/v1/vulnerabilities',
method: 'GET',
deprecation: {
deprecated: true,
sunsetAt: '2026-06-01T00:00:00Z',
replacedBy: '/api/v2/findings',
migrationGuide: 'https://docs.stellaops.local/migration/v2-findings',
},
},
],
total: 1,
};
getOpenApiSpec(options: OpenApiQueryOptions = {}): Observable<OpenApiSpecResponse> {
const traceId = options.traceId ?? generateTraceId();
// Simulate ETag caching
if (options.ifNoneMatch === this.mockSpec.etag) {
return throwError(() => new Error(`[${traceId}] Not Modified`)).pipe(delay(10));
}
return of({ ...this.mockSpec, traceId }).pipe(delay(50));
}
getGatewayInfo(_options: OpenApiQueryOptions = {}): Observable<GatewayInfo> {
return of({ ...this.mockGatewayInfo }).pipe(delay(30));
}
getGatewayHealth(options: OpenApiQueryOptions = {}): Observable<GatewayHealthCheck> {
const traceId = options.traceId ?? generateTraceId();
return of({
status: 'healthy' as const,
checks: [
{ name: 'database', status: 'healthy' as const, latencyMs: 5 },
{ name: 'cache', status: 'healthy' as const, latencyMs: 2 },
{ name: 'upstream', status: 'healthy' as const, latencyMs: 15 },
],
timestamp: new Date().toISOString(),
traceId,
}).pipe(delay(50));
}
getDeprecatedRoutes(options: OpenApiQueryOptions = {}): Observable<DeprecatedRoutesResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({ ...this.mockDeprecatedRoutes, traceId }).pipe(delay(30));
}
checkIdempotencyKey(key: string, _options: OpenApiQueryOptions = {}): Observable<IdempotencyResponse> {
return of({
idempotencyKey: key,
status: 'accepted' as const,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
}).pipe(delay(30));
}
getRateLimitInfo(_options: OpenApiQueryOptions = {}): Observable<RateLimitInfo> {
return of({
limit: 1000,
remaining: 950,
reset: Math.floor(Date.now() / 1000) + 3600,
}).pipe(delay(20));
}
}

View File

@@ -0,0 +1,138 @@
/**
* Gateway OpenAPI Models.
* Implements WEB-OAS-61-001, WEB-OAS-61-002, WEB-OAS-62-001, WEB-OAS-63-001.
*/
/** OpenAPI spec version info. */
export interface OpenApiVersionInfo {
readonly specVersion: string;
readonly gatewayVersion: string;
readonly buildTimestamp: string;
readonly gitCommit?: string;
}
/** OpenAPI spec response. */
export interface OpenApiSpecResponse {
readonly openapi: string;
readonly info: {
readonly title: string;
readonly version: string;
readonly description?: string;
};
readonly paths: Record<string, unknown>;
readonly components?: Record<string, unknown>;
readonly etag: string;
readonly versionInfo: OpenApiVersionInfo;
readonly traceId?: string;
}
/** Standard error envelope. */
export interface GatewayErrorEnvelope {
readonly error: {
readonly code: string;
readonly message: string;
readonly details?: readonly GatewayErrorDetail[];
readonly traceId: string;
readonly timestamp: string;
};
}
/** Error detail. */
export interface GatewayErrorDetail {
readonly field?: string;
readonly reason: string;
readonly value?: string;
}
/** Rate limit info. */
export interface RateLimitInfo {
readonly limit: number;
readonly remaining: number;
readonly reset: number;
readonly retryAfter?: number;
}
/** Pagination cursor. */
export interface PaginationCursor {
readonly pageToken?: string | null;
readonly pageSize?: number;
readonly hasMore?: boolean;
readonly total?: number;
}
/** Idempotency status. */
export type IdempotencyStatus = 'accepted' | 'duplicate' | 'expired';
/** Idempotency response. */
export interface IdempotencyResponse {
readonly idempotencyKey: string;
readonly status: IdempotencyStatus;
readonly originalRequestId?: string;
readonly expiresAt: string;
}
/** Deprecation info. */
export interface DeprecationInfo {
readonly deprecated: boolean;
readonly sunsetAt?: string;
readonly replacedBy?: string;
readonly migrationGuide?: string;
}
/** Deprecated route. */
export interface DeprecatedRoute {
readonly path: string;
readonly method: string;
readonly deprecation: DeprecationInfo;
}
/** Deprecated routes response. */
export interface DeprecatedRoutesResponse {
readonly items: readonly DeprecatedRoute[];
readonly total: number;
readonly traceId?: string;
}
/** Gateway info. */
export interface GatewayInfo {
readonly name: string;
readonly version: string;
readonly environment: string;
readonly region?: string;
readonly features: readonly string[];
readonly uptime?: number;
}
/** Gateway health status. */
export type GatewayHealthStatus = 'healthy' | 'degraded' | 'unhealthy';
/** Gateway health check. */
export interface GatewayHealthCheck {
readonly status: GatewayHealthStatus;
readonly checks: readonly {
readonly name: string;
readonly status: GatewayHealthStatus;
readonly message?: string;
readonly latencyMs?: number;
}[];
readonly timestamp: string;
readonly traceId?: string;
}
/** OpenAPI query options. */
export interface OpenApiQueryOptions {
readonly tenantId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Gateway error codes. */
export type GatewayErrorCode =
| 'ERR_GATEWAY_UNAUTHORIZED'
| 'ERR_GATEWAY_FORBIDDEN'
| 'ERR_GATEWAY_NOT_FOUND'
| 'ERR_GATEWAY_RATE_LIMIT'
| 'ERR_GATEWAY_VALIDATION'
| 'ERR_GATEWAY_IDEMPOTENCY'
| 'ERR_GATEWAY_UPSTREAM'
| 'ERR_GATEWAY_TIMEOUT';

View File

@@ -0,0 +1,448 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
GraphMetadata,
GraphListResponse,
GraphTileResponse,
GraphQueryOptions,
TileQueryOptions,
GraphSearchOptions,
GraphSearchResponse,
PathFindOptions,
PathFindResponse,
GraphExportOptions,
GraphExportResponse,
AssetSnapshot,
AdjacencyResponse,
GraphBuildStatus,
GraphNodeKind,
GraphSeverity,
GraphReachability,
GraphNode,
GraphEdge,
} from './graph-platform.models';
import { generateTraceId } from './trace.util';
export const GRAPH_API_BASE_URL = new InjectionToken<string>('GRAPH_API_BASE_URL');
/**
* Graph Platform API interface.
* Implements WEB-GRAPH-SPEC-21-000 through WEB-GRAPH-24-004.
*/
export interface GraphPlatformApi {
/** List available graphs. */
listGraphs(options?: GraphQueryOptions): Observable<GraphListResponse>;
/** Get graph metadata. */
getGraph(graphId: string, options?: GraphQueryOptions): Observable<GraphMetadata>;
/** Get graph tile with nodes, edges, and overlays. */
getTile(graphId: string, options?: TileQueryOptions): Observable<GraphTileResponse>;
/** Search graph nodes. */
search(options: GraphSearchOptions): Observable<GraphSearchResponse>;
/** Find paths between nodes. */
findPath(options: PathFindOptions): Observable<PathFindResponse>;
/** Export graph in various formats. */
exportGraph(graphId: string, options: GraphExportOptions): Observable<GraphExportResponse>;
/** Get asset snapshot. */
getAssetSnapshot(assetId: string, options?: GraphQueryOptions): Observable<AssetSnapshot>;
/** Get node adjacency. */
getAdjacency(nodeId: string, options?: GraphQueryOptions): Observable<AdjacencyResponse>;
}
export const GRAPH_PLATFORM_API = new InjectionToken<GraphPlatformApi>('GRAPH_PLATFORM_API');
/**
* HTTP Graph Platform Client.
*/
@Injectable({ providedIn: 'root' })
export class GraphPlatformHttpClient implements GraphPlatformApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(GRAPH_API_BASE_URL) private readonly baseUrl: string
) {}
listGraphs(options: GraphQueryOptions = {}): Observable<GraphListResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.pageToken) params = params.set('pageToken', options.pageToken);
if (options.pageSize) params = params.set('pageSize', String(options.pageSize));
if (options.status) params = params.set('status', options.status);
return this.http.get<GraphListResponse>(`${this.baseUrl}/graphs`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getGraph(graphId: string, options: GraphQueryOptions = {}): Observable<GraphMetadata> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<GraphMetadata>(
`${this.baseUrl}/graphs/${encodeURIComponent(graphId)}`,
{ headers }
).pipe(
map((response) => ({ ...response })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getTile(graphId: string, options: TileQueryOptions = {}): Observable<GraphTileResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.bbox) {
params = params.set('bbox', `${options.bbox.minX},${options.bbox.minY},${options.bbox.maxX},${options.bbox.maxY}`);
}
if (options.zoom !== undefined) params = params.set('zoom', String(options.zoom));
if (options.path) params = params.set('path', options.path);
if (options.includeOverlays !== undefined) params = params.set('includeOverlays', String(options.includeOverlays));
return this.http.get<GraphTileResponse>(
`${this.baseUrl}/graphs/${encodeURIComponent(graphId)}/tiles`,
{ headers, params }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
search(options: GraphSearchOptions): Observable<GraphSearchResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams().set('q', options.query);
if (options.pageToken) params = params.set('pageToken', options.pageToken);
if (options.pageSize) params = params.set('pageSize', String(options.pageSize));
if (options.kinds?.length) params = params.set('kinds', options.kinds.join(','));
if (options.severity?.length) params = params.set('severity', options.severity.join(','));
if (options.reachability?.length) params = params.set('reachability', options.reachability.join(','));
if (options.graphId) params = params.set('graphId', options.graphId);
return this.http.get<GraphSearchResponse>(`${this.baseUrl}/search`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
findPath(options: PathFindOptions): Observable<PathFindResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams()
.set('source', options.sourceId)
.set('target', options.targetId);
if (options.maxDepth) params = params.set('maxDepth', String(options.maxDepth));
if (options.includeEvidence !== undefined) params = params.set('includeEvidence', String(options.includeEvidence));
if (options.graphId) params = params.set('graphId', options.graphId);
return this.http.get<PathFindResponse>(`${this.baseUrl}/paths`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
exportGraph(graphId: string, options: GraphExportOptions): Observable<GraphExportResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read', 'graph:export'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:export scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams().set('format', options.format);
if (options.bbox) {
params = params.set('bbox', `${options.bbox.minX},${options.bbox.minY},${options.bbox.maxX},${options.bbox.maxY}`);
}
if (options.includeOverlays !== undefined) params = params.set('includeOverlays', String(options.includeOverlays));
return this.http.get<GraphExportResponse>(
`${this.baseUrl}/graphs/${encodeURIComponent(graphId)}/export`,
{ headers, params }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getAssetSnapshot(assetId: string, options: GraphQueryOptions = {}): Observable<AssetSnapshot> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
return this.http.get<AssetSnapshot>(
`${this.baseUrl}/assets/${encodeURIComponent(assetId)}/snapshot`,
{ headers }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getAdjacency(nodeId: string, options: GraphQueryOptions = {}): Observable<AdjacencyResponse> {
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('graph', 'read', ['graph:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing graph:read scope'));
}
const headers = this.buildHeaders(options);
let params = new HttpParams();
if (options.graphId) params = params.set('graphId', options.graphId);
return this.http.get<AdjacencyResponse>(
`${this.baseUrl}/nodes/${encodeURIComponent(nodeId)}/adjacency`,
{ headers, params }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(opts: { tenantId?: string; traceId?: string; ifNoneMatch?: string }): HttpHeaders {
const tenant = this.resolveTenant(opts.tenantId);
const trace = opts.traceId ?? generateTraceId();
let headers = new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': trace,
'X-Stella-Request-Id': trace,
Accept: 'application/json',
});
if (opts.ifNoneMatch) {
headers = headers.set('If-None-Match', opts.ifNoneMatch);
}
return headers;
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('GraphPlatformClient requires an active tenant identifier.');
}
return tenant;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Graph Platform error: ${err.message}`);
}
return new Error(`[${traceId}] Graph Platform error: Unknown error`);
}
}
/**
* Mock Graph Platform API for quickstart mode.
*/
@Injectable({ providedIn: 'root' })
export class MockGraphPlatformClient implements GraphPlatformApi {
private readonly mockGraphs: GraphMetadata[] = [
{
graphId: 'graph::tenant-default::main',
tenantId: 'tenant-default',
name: 'Main Dependency Graph',
description: 'Primary dependency graph for all projects',
status: 'ready',
nodeCount: 1250,
edgeCount: 3400,
snapshotAt: '2025-12-10T06:00:00Z',
createdAt: '2025-10-01T00:00:00Z',
updatedAt: '2025-12-10T06:00:00Z',
etag: '"graph-main-v1"',
},
];
private readonly mockNodes: GraphNode[] = [
{ id: 'asset::registry.local/ops/auth', kind: 'asset', label: 'auth-service', severity: 'high', reachability: 'reachable' },
{ id: 'component::pkg:npm/jsonwebtoken@9.0.2', kind: 'component', label: 'jsonwebtoken@9.0.2', severity: 'high', reachability: 'reachable' },
{ id: 'vuln::CVE-2024-12345', kind: 'vuln', label: 'CVE-2024-12345', severity: 'high' },
{ id: 'asset::registry.local/ops/transform', kind: 'asset', label: 'transform-service', severity: 'critical', reachability: 'reachable' },
{ id: 'component::pkg:npm/lodash@4.17.20', kind: 'component', label: 'lodash@4.17.20', severity: 'critical', reachability: 'reachable' },
{ id: 'vuln::CVE-2024-67890', kind: 'vuln', label: 'CVE-2024-67890', severity: 'critical' },
];
private readonly mockEdges: GraphEdge[] = [
{ id: 'edge-1', source: 'asset::registry.local/ops/auth', target: 'component::pkg:npm/jsonwebtoken@9.0.2', type: 'contains' },
{ id: 'edge-2', source: 'component::pkg:npm/jsonwebtoken@9.0.2', target: 'vuln::CVE-2024-12345', type: 'affects' },
{ id: 'edge-3', source: 'asset::registry.local/ops/transform', target: 'component::pkg:npm/lodash@4.17.20', type: 'contains' },
{ id: 'edge-4', source: 'component::pkg:npm/lodash@4.17.20', target: 'vuln::CVE-2024-67890', type: 'affects' },
];
listGraphs(options: GraphQueryOptions = {}): Observable<GraphListResponse> {
const traceId = options.traceId ?? generateTraceId();
let filtered = [...this.mockGraphs];
if (options.status) {
filtered = filtered.filter((g) => g.status === options.status);
}
return of({ items: filtered, total: filtered.length, traceId }).pipe(delay(50));
}
getGraph(graphId: string, options: GraphQueryOptions = {}): Observable<GraphMetadata> {
const graph = this.mockGraphs.find((g) => g.graphId === graphId);
if (!graph) {
return throwError(() => new Error(`Graph ${graphId} not found`));
}
return of(graph).pipe(delay(30));
}
getTile(graphId: string, options: TileQueryOptions = {}): Observable<GraphTileResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
version: '2025-12-06',
tenantId: 'tenant-default',
tile: {
id: `graph-tile::${graphId}::z${options.zoom ?? 8}`,
zoom: options.zoom ?? 8,
etag: '"tile-v1"',
},
nodes: this.mockNodes,
edges: this.mockEdges,
overlays: options.includeOverlays ? {
policy: [
{ nodeId: 'component::pkg:npm/jsonwebtoken@9.0.2', badge: 'fail', policyId: 'policy://tenant-default/runtime', verdictAt: '2025-12-10T06:00:00Z' },
{ nodeId: 'component::pkg:npm/lodash@4.17.20', badge: 'fail', policyId: 'policy://tenant-default/runtime', verdictAt: '2025-12-10T06:00:00Z' },
],
vex: [
{ nodeId: 'vuln::CVE-2024-12345', state: 'under_investigation', statementId: 'vex:tenant-default:jwt-auth:5d1a', lastUpdated: '2025-12-10T06:00:00Z' },
{ nodeId: 'vuln::CVE-2024-67890', state: 'affected', statementId: 'vex:tenant-default:data-transform:9bf4', lastUpdated: '2025-12-10T06:00:00Z' },
],
aoc: [],
} : undefined,
telemetry: { generationMs: 45, cache: 'miss', samples: this.mockNodes.length },
traceId,
etag: '"tile-response-v1"',
}).pipe(delay(75));
}
search(options: GraphSearchOptions): Observable<GraphSearchResponse> {
const traceId = options.traceId ?? generateTraceId();
const query = options.query.toLowerCase();
const results = this.mockNodes
.filter((n) => n.label.toLowerCase().includes(query) || n.id.toLowerCase().includes(query))
.filter((n) => !options.kinds?.length || options.kinds.includes(n.kind))
.filter((n) => !options.severity?.length || (n.severity && options.severity.includes(n.severity)))
.filter((n) => !options.reachability?.length || (n.reachability && options.reachability.includes(n.reachability)))
.map((n, i) => ({
nodeId: n.id,
kind: n.kind,
label: n.label,
score: 1 - i * 0.1,
severity: n.severity,
reachability: n.reachability,
highlights: [n.label],
}));
return of({ items: results, total: results.length, traceId }).pipe(delay(50));
}
findPath(options: PathFindOptions): Observable<PathFindResponse> {
const traceId = options.traceId ?? generateTraceId();
// Simplified path finding for mock
const sourceNode = this.mockNodes.find((n) => n.id === options.sourceId);
const targetNode = this.mockNodes.find((n) => n.id === options.targetId);
if (!sourceNode || !targetNode) {
return of({ paths: [], totalPaths: 0, traceId }).pipe(delay(30));
}
// Check if there's a direct edge
const directEdge = this.mockEdges.find((e) => e.source === options.sourceId && e.target === options.targetId);
if (directEdge) {
return of({
paths: [[
{ node: sourceNode, depth: 0 },
{ node: targetNode, edge: directEdge, depth: 1 },
]],
shortestLength: 1,
totalPaths: 1,
traceId,
}).pipe(delay(50));
}
return of({ paths: [], totalPaths: 0, traceId }).pipe(delay(30));
}
exportGraph(graphId: string, options: GraphExportOptions): Observable<GraphExportResponse> {
const traceId = options.traceId ?? generateTraceId();
const exportId = `graph-export::${graphId}::${Date.now()}`;
return of({
exportId,
format: options.format,
url: `https://exports.local/graphs/${graphId}/export.${options.format}?sig=mock`,
sha256: 'sha256:graphexport1234',
size: 1024 * 100,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
traceId,
}).pipe(delay(100));
}
getAssetSnapshot(assetId: string, options: GraphQueryOptions = {}): Observable<AssetSnapshot> {
const traceId = options.traceId ?? generateTraceId();
return of({
assetId,
name: assetId.split('::').pop() ?? assetId,
kind: 'container',
components: ['pkg:npm/jsonwebtoken@9.0.2', 'pkg:npm/express@4.18.1'],
vulnerabilities: ['CVE-2024-12345'],
snapshotAt: new Date().toISOString(),
traceId,
}).pipe(delay(30));
}
getAdjacency(nodeId: string, options: GraphQueryOptions = {}): Observable<AdjacencyResponse> {
const traceId = options.traceId ?? generateTraceId();
const incoming = this.mockEdges.filter((e) => e.target === nodeId).map((e) => ({ nodeId: e.source, edgeType: e.type }));
const outgoing = this.mockEdges.filter((e) => e.source === nodeId).map((e) => ({ nodeId: e.target, edgeType: e.type }));
return of({ nodeId, incoming, outgoing, traceId }).pipe(delay(30));
}
}

View File

@@ -0,0 +1,256 @@
/**
* Graph Platform Models.
* Implements WEB-GRAPH-SPEC-21-000 through WEB-GRAPH-24-004.
*/
/** Graph build status. */
export type GraphBuildStatus = 'pending' | 'building' | 'ready' | 'failed' | 'expired';
/** Node kind. */
export type GraphNodeKind = 'asset' | 'component' | 'vuln' | 'advisory' | 'policy' | 'evidence';
/** Severity level. */
export type GraphSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
/** Reachability status. */
export type GraphReachability = 'reachable' | 'unreachable' | 'unknown';
/** Edge type. */
export type GraphEdgeType = 'depends_on' | 'contains' | 'evidence' | 'affects' | 'mitigates';
/** Policy badge. */
export type GraphPolicyBadge = 'pass' | 'warn' | 'fail' | 'waived';
/** VEX state. */
export type GraphVexState = 'not_affected' | 'fixed' | 'under_investigation' | 'affected';
/** AOC status. */
export type GraphAocStatus = 'pass' | 'fail' | 'warn' | 'pending';
/** Graph metadata. */
export interface GraphMetadata {
readonly graphId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly status: GraphBuildStatus;
readonly nodeCount?: number;
readonly edgeCount?: number;
readonly snapshotAt?: string;
readonly createdAt: string;
readonly updatedAt?: string;
readonly etag?: string;
}
/** Graph list response. */
export interface GraphListResponse {
readonly items: readonly GraphMetadata[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Graph node. */
export interface GraphNode {
readonly id: string;
readonly kind: GraphNodeKind;
readonly label: string;
readonly severity?: GraphSeverity;
readonly reachability?: GraphReachability;
readonly attributes?: Record<string, unknown>;
}
/** Graph edge. */
export interface GraphEdge {
readonly id: string;
readonly source: string;
readonly target: string;
readonly type: GraphEdgeType;
readonly weight?: number;
readonly attributes?: Record<string, unknown>;
}
/** Policy overlay. */
export interface PolicyOverlay {
readonly nodeId: string;
readonly badge: GraphPolicyBadge;
readonly policyId: string;
readonly verdictAt?: string;
}
/** VEX overlay. */
export interface VexOverlay {
readonly nodeId: string;
readonly state: GraphVexState;
readonly statementId: string;
readonly lastUpdated?: string;
}
/** AOC overlay. */
export interface AocOverlay {
readonly nodeId: string;
readonly status: GraphAocStatus;
readonly lastVerified?: string;
}
/** Graph overlays. */
export interface GraphOverlays {
readonly policy?: readonly PolicyOverlay[];
readonly vex?: readonly VexOverlay[];
readonly aoc?: readonly AocOverlay[];
}
/** Tile bounding box. */
export interface TileBbox {
readonly minX: number;
readonly minY: number;
readonly maxX: number;
readonly maxY: number;
}
/** Tile metadata. */
export interface TileMetadata {
readonly id: string;
readonly bbox?: TileBbox;
readonly zoom?: number;
readonly etag?: string;
}
/** Graph tile telemetry. */
export interface TileTelemetry {
readonly generationMs?: number;
readonly cache?: 'hit' | 'miss';
readonly samples?: number;
}
/** Graph tile response. */
export interface GraphTileResponse {
readonly version: string;
readonly tenantId: string;
readonly tile: TileMetadata;
readonly nodes: readonly GraphNode[];
readonly edges: readonly GraphEdge[];
readonly overlays?: GraphOverlays;
readonly telemetry?: TileTelemetry;
readonly traceId?: string;
readonly etag?: string;
}
/** Graph query options. */
export interface GraphQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly graphId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly status?: GraphBuildStatus;
readonly traceId?: string;
readonly ifNoneMatch?: string;
}
/** Tile query options. */
export interface TileQueryOptions extends GraphQueryOptions {
readonly bbox?: TileBbox;
readonly zoom?: number;
readonly path?: string;
readonly includeOverlays?: boolean;
}
/** Search query options. */
export interface GraphSearchOptions extends GraphQueryOptions {
readonly query: string;
readonly kinds?: readonly GraphNodeKind[];
readonly severity?: readonly GraphSeverity[];
readonly reachability?: readonly GraphReachability[];
}
/** Search result. */
export interface GraphSearchResult {
readonly nodeId: string;
readonly kind: GraphNodeKind;
readonly label: string;
readonly score: number;
readonly severity?: GraphSeverity;
readonly reachability?: GraphReachability;
readonly highlights?: readonly string[];
}
/** Search response. */
export interface GraphSearchResponse {
readonly items: readonly GraphSearchResult[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Path finding options. */
export interface PathFindOptions extends GraphQueryOptions {
readonly sourceId: string;
readonly targetId: string;
readonly maxDepth?: number;
readonly includeEvidence?: boolean;
}
/** Path step. */
export interface PathStep {
readonly node: GraphNode;
readonly edge?: GraphEdge;
readonly depth: number;
}
/** Path finding response. */
export interface PathFindResponse {
readonly paths: readonly (readonly PathStep[])[];
readonly shortestLength?: number;
readonly totalPaths?: number;
readonly traceId?: string;
}
/** Export format. */
export type GraphExportFormat = 'ndjson' | 'csv' | 'graphml' | 'png' | 'svg';
/** Graph export options. */
export interface GraphExportOptions extends GraphQueryOptions {
readonly format: GraphExportFormat;
readonly bbox?: TileBbox;
readonly includeOverlays?: boolean;
}
/** Graph export response. */
export interface GraphExportResponse {
readonly exportId: string;
readonly format: GraphExportFormat;
readonly url: string;
readonly sha256?: string;
readonly size?: number;
readonly expiresAt?: string;
readonly traceId?: string;
}
/** Asset snapshot. */
export interface AssetSnapshot {
readonly assetId: string;
readonly name: string;
readonly kind: string;
readonly components?: readonly string[];
readonly vulnerabilities?: readonly string[];
readonly snapshotAt: string;
readonly traceId?: string;
}
/** Adjacency list response. */
export interface AdjacencyResponse {
readonly nodeId: string;
readonly incoming: readonly { nodeId: string; edgeType: GraphEdgeType }[];
readonly outgoing: readonly { nodeId: string; edgeType: GraphEdgeType }[];
readonly traceId?: string;
}
/** Graph error codes. */
export type GraphErrorCode =
| 'ERR_GRAPH_NOT_FOUND'
| 'ERR_GRAPH_INVALID_BBOX'
| 'ERR_GRAPH_INVALID_ZOOM'
| 'ERR_GRAPH_TOO_LARGE'
| 'ERR_GRAPH_RATE_LIMIT'
| 'ERR_GRAPH_EXPORT_FAILED';

View File

@@ -5,8 +5,11 @@ import {
InjectionToken,
Optional,
} from '@angular/core';
import { Observable } from 'rxjs';
import { Observable, of, throwError } from 'rxjs';
import { map, catchError, delay } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import {
ChannelHealthResponse,
ChannelTestSendRequest,
@@ -15,9 +18,28 @@ import {
NotifyDeliveriesQueryOptions,
NotifyDeliveriesResponse,
NotifyRule,
DigestSchedule,
DigestSchedulesResponse,
QuietHours,
QuietHoursResponse,
ThrottleConfig,
ThrottleConfigsResponse,
NotifySimulationRequest,
NotifySimulationResult,
EscalationPolicy,
EscalationPoliciesResponse,
LocalizationConfig,
LocalizationConfigsResponse,
NotifyIncident,
NotifyIncidentsResponse,
AckRequest,
AckResponse,
NotifyQueryOptions,
} from './notify.models';
import { generateTraceId } from './trace.util';
export interface NotifyApi {
// WEB-NOTIFY-38-001: Base notification APIs
listChannels(): Observable<NotifyChannel[]>;
saveChannel(channel: NotifyChannel): Observable<NotifyChannel>;
deleteChannel(channelId: string): Observable<void>;
@@ -32,6 +54,29 @@ export interface NotifyApi {
listDeliveries(
options?: NotifyDeliveriesQueryOptions
): Observable<NotifyDeliveriesResponse>;
// WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management
listDigestSchedules(options?: NotifyQueryOptions): Observable<DigestSchedulesResponse>;
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule>;
deleteDigestSchedule(scheduleId: string): Observable<void>;
listQuietHours(options?: NotifyQueryOptions): Observable<QuietHoursResponse>;
saveQuietHours(quietHours: QuietHours): Observable<QuietHours>;
deleteQuietHours(quietHoursId: string): Observable<void>;
listThrottleConfigs(options?: NotifyQueryOptions): Observable<ThrottleConfigsResponse>;
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig>;
deleteThrottleConfig(throttleId: string): Observable<void>;
simulateNotification(request: NotifySimulationRequest, options?: NotifyQueryOptions): Observable<NotifySimulationResult>;
// WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification
listEscalationPolicies(options?: NotifyQueryOptions): Observable<EscalationPoliciesResponse>;
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy>;
deleteEscalationPolicy(policyId: string): Observable<void>;
listLocalizations(options?: NotifyQueryOptions): Observable<LocalizationConfigsResponse>;
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig>;
deleteLocalization(localeId: string): Observable<void>;
listIncidents(options?: NotifyQueryOptions): Observable<NotifyIncidentsResponse>;
getIncident(incidentId: string, options?: NotifyQueryOptions): Observable<NotifyIncident>;
acknowledgeIncident(incidentId: string, request: AckRequest, options?: NotifyQueryOptions): Observable<AckResponse>;
}
export const NOTIFY_API = new InjectionToken<NotifyApi>('NOTIFY_API');
@@ -42,10 +87,16 @@ export const NOTIFY_API_BASE_URL = new InjectionToken<string>(
export const NOTIFY_TENANT_ID = new InjectionToken<string>('NOTIFY_TENANT_ID');
/**
* HTTP Notify Client.
* Implements WEB-NOTIFY-38-001, WEB-NOTIFY-39-001, WEB-NOTIFY-40-001.
*/
@Injectable({ providedIn: 'root' })
export class NotifyApiHttpClient implements NotifyApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(NOTIFY_API_BASE_URL) private readonly baseUrl: string,
@Optional() @Inject(NOTIFY_TENANT_ID) private readonly tenantId: string | null
) {}
@@ -131,6 +182,185 @@ export class NotifyApiHttpClient implements NotifyApi {
});
}
// WEB-NOTIFY-39-001: Digest scheduling
listDigestSchedules(options: NotifyQueryOptions = {}): Observable<DigestSchedulesResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<DigestSchedulesResponse>(`${this.baseUrl}/digest-schedules`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<DigestSchedule>(`${this.baseUrl}/digest-schedules`, schedule, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteDigestSchedule(scheduleId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/digest-schedules/${encodeURIComponent(scheduleId)}`, { headers });
}
// WEB-NOTIFY-39-001: Quiet hours
listQuietHours(options: NotifyQueryOptions = {}): Observable<QuietHoursResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<QuietHoursResponse>(`${this.baseUrl}/quiet-hours`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveQuietHours(quietHours: QuietHours): Observable<QuietHours> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<QuietHours>(`${this.baseUrl}/quiet-hours`, quietHours, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteQuietHours(quietHoursId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/quiet-hours/${encodeURIComponent(quietHoursId)}`, { headers });
}
// WEB-NOTIFY-39-001: Throttle configs
listThrottleConfigs(options: NotifyQueryOptions = {}): Observable<ThrottleConfigsResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<ThrottleConfigsResponse>(`${this.baseUrl}/throttle-configs`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<ThrottleConfig>(`${this.baseUrl}/throttle-configs`, config, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteThrottleConfig(throttleId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/throttle-configs/${encodeURIComponent(throttleId)}`, { headers });
}
// WEB-NOTIFY-39-001: Simulation
simulateNotification(request: NotifySimulationRequest, options: NotifyQueryOptions = {}): Observable<NotifySimulationResult> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<NotifySimulationResult>(`${this.baseUrl}/simulate`, request, { headers }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
// WEB-NOTIFY-40-001: Escalation policies
listEscalationPolicies(options: NotifyQueryOptions = {}): Observable<EscalationPoliciesResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<EscalationPoliciesResponse>(`${this.baseUrl}/escalation-policies`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<EscalationPolicy>(`${this.baseUrl}/escalation-policies`, policy, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteEscalationPolicy(policyId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/escalation-policies/${encodeURIComponent(policyId)}`, { headers });
}
// WEB-NOTIFY-40-001: Localization
listLocalizations(options: NotifyQueryOptions = {}): Observable<LocalizationConfigsResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<LocalizationConfigsResponse>(`${this.baseUrl}/localizations`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig> {
const traceId = generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<LocalizationConfig>(`${this.baseUrl}/localizations`, config, { headers }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
deleteLocalization(localeId: string): Observable<void> {
const headers = this.buildHeaders();
return this.http.delete<void>(`${this.baseUrl}/localizations/${encodeURIComponent(localeId)}`, { headers });
}
// WEB-NOTIFY-40-001: Incidents and acknowledgment
listIncidents(options: NotifyQueryOptions = {}): Observable<NotifyIncidentsResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
const params = this.buildPaginationParams(options);
return this.http.get<NotifyIncidentsResponse>(`${this.baseUrl}/incidents`, { headers, params }).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getIncident(incidentId: string, options: NotifyQueryOptions = {}): Observable<NotifyIncident> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.get<NotifyIncident>(
`${this.baseUrl}/incidents/${encodeURIComponent(incidentId)}`,
{ headers }
).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
acknowledgeIncident(incidentId: string, request: AckRequest, options: NotifyQueryOptions = {}): Observable<AckResponse> {
const traceId = options.traceId ?? generateTraceId();
const headers = this.buildHeadersWithTrace(traceId);
return this.http.post<AckResponse>(
`${this.baseUrl}/incidents/${encodeURIComponent(incidentId)}/ack`,
request,
{ headers }
).pipe(
map((response) => ({ ...response, traceId })),
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(): HttpHeaders {
if (!this.tenantId) {
return new HttpHeaders();
@@ -138,5 +368,356 @@ export class NotifyApiHttpClient implements NotifyApi {
return new HttpHeaders({ 'X-StellaOps-Tenant': this.tenantId });
}
private buildHeadersWithTrace(traceId: string): HttpHeaders {
const tenant = this.tenantId || this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
Accept: 'application/json',
});
}
private buildPaginationParams(options: NotifyQueryOptions): HttpParams {
let params = new HttpParams();
if (options.pageToken) {
params = params.set('pageToken', options.pageToken);
}
if (options.pageSize) {
params = params.set('pageSize', String(options.pageSize));
}
return params;
}
private mapError(err: unknown, traceId: string): Error {
if (err instanceof Error) {
return new Error(`[${traceId}] Notify error: ${err.message}`);
}
return new Error(`[${traceId}] Notify error: Unknown error`);
}
}
/**
* Mock Notify Client for quickstart mode.
* Implements WEB-NOTIFY-38-001, WEB-NOTIFY-39-001, WEB-NOTIFY-40-001.
*/
@Injectable({ providedIn: 'root' })
export class MockNotifyClient implements NotifyApi {
private readonly mockChannels: NotifyChannel[] = [
{
channelId: 'chn-soc-webhook',
tenantId: 'tenant-default',
name: 'SOC Webhook',
displayName: 'Security Operations Center',
type: 'Webhook',
enabled: true,
config: {
secretRef: 'secret://notify/soc-webhook',
endpoint: 'https://soc.example.com/webhooks/stellaops',
},
createdAt: '2025-10-01T00:00:00Z',
},
{
channelId: 'chn-slack-dev',
tenantId: 'tenant-default',
name: 'Slack Dev',
displayName: 'Development Team Slack',
type: 'Slack',
enabled: true,
config: {
secretRef: 'secret://notify/slack-dev',
target: '#dev-alerts',
},
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockRules: NotifyRule[] = [
{
ruleId: 'rule-critical-vulns',
tenantId: 'tenant-default',
name: 'Critical Vulnerabilities',
enabled: true,
match: { minSeverity: 'critical', kevOnly: true },
actions: [
{ actionId: 'act-soc', channel: 'chn-soc-webhook', digest: 'instant', enabled: true },
],
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockDigestSchedules: DigestSchedule[] = [
{
scheduleId: 'digest-daily',
tenantId: 'tenant-default',
name: 'Daily Digest',
frequency: 'daily',
timezone: 'UTC',
hour: 8,
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockQuietHours: QuietHours[] = [
{
quietHoursId: 'qh-default',
tenantId: 'tenant-default',
name: 'Weeknight Quiet',
windows: [
{ timezone: 'UTC', days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], start: '22:00', end: '06:00' },
],
exemptions: [
{ eventKinds: ['attestor.verification.failed'], reason: 'Always alert on attestation failures' },
],
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockThrottleConfigs: ThrottleConfig[] = [
{
throttleId: 'throttle-default',
tenantId: 'tenant-default',
name: 'Default Throttle',
windowSeconds: 60,
maxEvents: 50,
burstLimit: 100,
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockEscalationPolicies: EscalationPolicy[] = [
{
policyId: 'escalate-critical',
tenantId: 'tenant-default',
name: 'Critical Escalation',
levels: [
{ level: 1, delayMinutes: 0, channels: ['chn-soc-webhook'], notifyOnAck: false },
{ level: 2, delayMinutes: 15, channels: ['chn-slack-dev'], notifyOnAck: true },
],
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockLocalizations: LocalizationConfig[] = [
{
localeId: 'loc-en-us',
tenantId: 'tenant-default',
locale: 'en-US',
name: 'English (US)',
templates: { 'vuln.critical': 'Critical vulnerability detected: {{title}}' },
dateFormat: 'MM/DD/YYYY',
timeFormat: 'HH:mm:ss',
enabled: true,
createdAt: '2025-10-01T00:00:00Z',
},
];
private readonly mockIncidents: NotifyIncident[] = [
{
incidentId: 'inc-001',
tenantId: 'tenant-default',
title: 'Critical vulnerability CVE-2021-44228',
severity: 'critical',
status: 'open',
eventIds: ['evt-001', 'evt-002'],
escalationLevel: 1,
escalationPolicyId: 'escalate-critical',
createdAt: '2025-12-10T10:00:00Z',
},
];
// WEB-NOTIFY-38-001: Base APIs
listChannels(): Observable<NotifyChannel[]> {
return of([...this.mockChannels]).pipe(delay(50));
}
saveChannel(channel: NotifyChannel): Observable<NotifyChannel> {
return of(channel).pipe(delay(50));
}
deleteChannel(_channelId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
getChannelHealth(channelId: string): Observable<ChannelHealthResponse> {
return of({
tenantId: 'tenant-default',
channelId,
status: 'Healthy' as const,
checkedAt: new Date().toISOString(),
traceId: generateTraceId(),
}).pipe(delay(50));
}
testChannel(channelId: string, payload: ChannelTestSendRequest): Observable<ChannelTestSendResponse> {
return of({
tenantId: 'tenant-default',
channelId,
preview: {
channelType: 'Webhook' as const,
format: 'Json' as const,
target: 'https://soc.example.com/webhooks/stellaops',
title: payload.title || 'Test notification',
body: payload.body || 'Test notification body',
},
queuedAt: new Date().toISOString(),
traceId: generateTraceId(),
}).pipe(delay(100));
}
listRules(): Observable<NotifyRule[]> {
return of([...this.mockRules]).pipe(delay(50));
}
saveRule(rule: NotifyRule): Observable<NotifyRule> {
return of(rule).pipe(delay(50));
}
deleteRule(_ruleId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listDeliveries(_options?: NotifyDeliveriesQueryOptions): Observable<NotifyDeliveriesResponse> {
return of({ items: [], count: 0 }).pipe(delay(50));
}
// WEB-NOTIFY-39-001: Digest, quiet hours, throttle
listDigestSchedules(options: NotifyQueryOptions = {}): Observable<DigestSchedulesResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockDigestSchedules],
total: this.mockDigestSchedules.length,
traceId,
}).pipe(delay(50));
}
saveDigestSchedule(schedule: DigestSchedule): Observable<DigestSchedule> {
return of(schedule).pipe(delay(50));
}
deleteDigestSchedule(_scheduleId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listQuietHours(options: NotifyQueryOptions = {}): Observable<QuietHoursResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockQuietHours],
total: this.mockQuietHours.length,
traceId,
}).pipe(delay(50));
}
saveQuietHours(quietHours: QuietHours): Observable<QuietHours> {
return of(quietHours).pipe(delay(50));
}
deleteQuietHours(_quietHoursId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listThrottleConfigs(options: NotifyQueryOptions = {}): Observable<ThrottleConfigsResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockThrottleConfigs],
total: this.mockThrottleConfigs.length,
traceId,
}).pipe(delay(50));
}
saveThrottleConfig(config: ThrottleConfig): Observable<ThrottleConfig> {
return of(config).pipe(delay(50));
}
deleteThrottleConfig(_throttleId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
simulateNotification(request: NotifySimulationRequest, options: NotifyQueryOptions = {}): Observable<NotifySimulationResult> {
const traceId = options.traceId ?? generateTraceId();
return of({
simulationId: `sim-${Date.now()}`,
matchedRules: ['rule-critical-vulns'],
wouldNotify: [
{
channelId: 'chn-soc-webhook',
actionId: 'act-soc',
template: 'tmpl-default',
digest: 'instant' as const,
},
],
throttled: false,
quietHoursActive: false,
traceId,
}).pipe(delay(100));
}
// WEB-NOTIFY-40-001: Escalation, localization, incidents
listEscalationPolicies(options: NotifyQueryOptions = {}): Observable<EscalationPoliciesResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockEscalationPolicies],
total: this.mockEscalationPolicies.length,
traceId,
}).pipe(delay(50));
}
saveEscalationPolicy(policy: EscalationPolicy): Observable<EscalationPolicy> {
return of(policy).pipe(delay(50));
}
deleteEscalationPolicy(_policyId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listLocalizations(options: NotifyQueryOptions = {}): Observable<LocalizationConfigsResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockLocalizations],
total: this.mockLocalizations.length,
traceId,
}).pipe(delay(50));
}
saveLocalization(config: LocalizationConfig): Observable<LocalizationConfig> {
return of(config).pipe(delay(50));
}
deleteLocalization(_localeId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
listIncidents(options: NotifyQueryOptions = {}): Observable<NotifyIncidentsResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
items: [...this.mockIncidents],
total: this.mockIncidents.length,
traceId,
}).pipe(delay(50));
}
getIncident(incidentId: string, _options: NotifyQueryOptions = {}): Observable<NotifyIncident> {
const incident = this.mockIncidents.find((i) => i.incidentId === incidentId);
if (!incident) {
return throwError(() => new Error(`Incident not found: ${incidentId}`));
}
return of(incident).pipe(delay(50));
}
acknowledgeIncident(incidentId: string, _request: AckRequest, options: NotifyQueryOptions = {}): Observable<AckResponse> {
const traceId = options.traceId ?? generateTraceId();
return of({
incidentId,
acknowledged: true,
acknowledgedAt: new Date().toISOString(),
acknowledgedBy: 'user@example.com',
traceId,
}).pipe(delay(100));
}
}

View File

@@ -192,3 +192,228 @@ export interface ChannelTestSendResponse {
readonly metadata?: Record<string, string>;
}
/**
* WEB-NOTIFY-39-001: Digest scheduling, quiet-hours, throttle management.
*/
/** Digest frequency. */
export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly';
/** Digest schedule. */
export interface DigestSchedule {
readonly scheduleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly frequency: DigestFrequency;
readonly timezone: string;
readonly hour?: number;
readonly dayOfWeek?: number;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Digest schedules response. */
export interface DigestSchedulesResponse {
readonly items: readonly DigestSchedule[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Quiet hour window. */
export interface QuietHourWindow {
readonly timezone: string;
readonly days: readonly string[];
readonly start: string;
readonly end: string;
}
/** Quiet hour exemption. */
export interface QuietHourExemption {
readonly eventKinds: readonly string[];
readonly reason: string;
}
/** Quiet hours configuration. */
export interface QuietHours {
readonly quietHoursId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly windows: readonly QuietHourWindow[];
readonly exemptions?: readonly QuietHourExemption[];
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Quiet hours response. */
export interface QuietHoursResponse {
readonly items: readonly QuietHours[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Throttle configuration. */
export interface ThrottleConfig {
readonly throttleId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly windowSeconds: number;
readonly maxEvents: number;
readonly burstLimit?: number;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Throttle configs response. */
export interface ThrottleConfigsResponse {
readonly items: readonly ThrottleConfig[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Simulation request. */
export interface NotifySimulationRequest {
readonly eventKind: string;
readonly payload: Record<string, unknown>;
readonly targetChannels?: readonly string[];
readonly dryRun: boolean;
}
/** Simulation result. */
export interface NotifySimulationResult {
readonly simulationId: string;
readonly matchedRules: readonly string[];
readonly wouldNotify: readonly {
readonly channelId: string;
readonly actionId: string;
readonly template: string;
readonly digest: DigestFrequency;
}[];
readonly throttled: boolean;
readonly quietHoursActive: boolean;
readonly traceId?: string;
}
/**
* WEB-NOTIFY-40-001: Escalation, localization, channel health, ack verification.
*/
/** Escalation policy. */
export interface EscalationPolicy {
readonly policyId: string;
readonly tenantId: string;
readonly name: string;
readonly description?: string;
readonly levels: readonly EscalationLevel[];
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Escalation level. */
export interface EscalationLevel {
readonly level: number;
readonly delayMinutes: number;
readonly channels: readonly string[];
readonly notifyOnAck: boolean;
}
/** Escalation policies response. */
export interface EscalationPoliciesResponse {
readonly items: readonly EscalationPolicy[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Localization config. */
export interface LocalizationConfig {
readonly localeId: string;
readonly tenantId: string;
readonly locale: string;
readonly name: string;
readonly templates: Record<string, string>;
readonly dateFormat?: string;
readonly timeFormat?: string;
readonly timezone?: string;
readonly enabled: boolean;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Localization configs response. */
export interface LocalizationConfigsResponse {
readonly items: readonly LocalizationConfig[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Incident for acknowledgment. */
export interface NotifyIncident {
readonly incidentId: string;
readonly tenantId: string;
readonly title: string;
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
readonly status: 'open' | 'acknowledged' | 'resolved' | 'closed';
readonly eventIds: readonly string[];
readonly escalationLevel?: number;
readonly escalationPolicyId?: string;
readonly assignee?: string;
readonly acknowledgedAt?: string;
readonly acknowledgedBy?: string;
readonly resolvedAt?: string;
readonly resolvedBy?: string;
readonly createdAt: string;
readonly updatedAt?: string;
}
/** Incidents response. */
export interface NotifyIncidentsResponse {
readonly items: readonly NotifyIncident[];
readonly nextPageToken?: string | null;
readonly total?: number;
readonly traceId?: string;
}
/** Acknowledgment request. */
export interface AckRequest {
readonly ackToken: string;
readonly note?: string;
}
/** Acknowledgment response. */
export interface AckResponse {
readonly incidentId: string;
readonly acknowledged: boolean;
readonly acknowledgedAt: string;
readonly acknowledgedBy: string;
readonly traceId?: string;
}
/** Notify query options. */
export interface NotifyQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly pageToken?: string;
readonly pageSize?: number;
readonly traceId?: string;
}
/** Notify error codes. */
export type NotifyErrorCode =
| 'ERR_NOTIFY_CHANNEL_NOT_FOUND'
| 'ERR_NOTIFY_RULE_NOT_FOUND'
| 'ERR_NOTIFY_INVALID_CONFIG'
| 'ERR_NOTIFY_RATE_LIMIT'
| 'ERR_NOTIFY_ACK_INVALID'
| 'ERR_NOTIFY_ACK_EXPIRED';