From b08a9534a1121be7d8d8eeec17d3c2eea15b0360 Mon Sep 17 00:00:00 2001 From: collinschreyer-dev Date: Mon, 31 Aug 2026 12:16:50 -0500 Subject: [PATCH] Send bearer token on raw fetch calls to rag-analytics Companion to the srt-api change that adds token() and admin_only() to the rag-analytics routes. Without this, those calls would start returning 401. Angular attaches the bearer token through TokenInterceptor, which only sees HttpClient traffic. Sixteen calls in the analytics and home components used raw fetch() instead, which bypasses the interceptor and sent no Authorization header. That is why those endpoints had no server-side guard: adding one would have broken them. art-lookup, the single guarded route, was also the single one called through HttpClient. Adds authHeaders() in shared/services and applies it at each fetch call site. home.component already did this by hand for its streaming analyze call, so this generalizes an existing local fix rather than introducing a new pattern. The execute-pipeline upload deliberately gets no Content-Type, so the browser can set the multipart boundary for its FormData body. No request logic changed beyond the added header. Verified with a production build and a typecheck of the touched files. Co-Authored-By: Claude Opus 5 --- .../ai-analytics/ai-analytics.component.ts | 3 ++- .../ai-pipeline/ai-pipeline.component.ts | 5 ++-- .../ai-playground/ai-playground.component.ts | 27 ++++++++++--------- src/app/home/home/home.component.ts | 3 ++- src/app/shared/services/auth-fetch.ts | 19 +++++++++++++ 5 files changed, 39 insertions(+), 18 deletions(-) create mode 100644 src/app/shared/services/auth-fetch.ts diff --git a/src/app/analytics/ai-analytics/ai-analytics.component.ts b/src/app/analytics/ai-analytics/ai-analytics.component.ts index afe55d1..eb981f7 100644 --- a/src/app/analytics/ai-analytics/ai-analytics.component.ts +++ b/src/app/analytics/ai-analytics/ai-analytics.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { environment } from '../../../environments/environment'; +import { authHeaders } from '../../shared/services/auth-fetch'; @Component({ selector: 'app-ai-analytics', @@ -18,7 +19,7 @@ export class AiAnalyticsComponent implements OnInit { fetchUsage() { this.loading = true; - fetch(`${environment.SERVER_URL}/rag-analytics/adhoc-usage`) + fetch(`${environment.SERVER_URL}/rag-analytics/adhoc-usage`, { headers: authHeaders() }) .then(res => res.json()) .then(data => { this.usage = data; diff --git a/src/app/analytics/ai-pipeline/ai-pipeline.component.ts b/src/app/analytics/ai-pipeline/ai-pipeline.component.ts index 2f3492a..7d8d8bc 100644 --- a/src/app/analytics/ai-pipeline/ai-pipeline.component.ts +++ b/src/app/analytics/ai-pipeline/ai-pipeline.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { authHeaders } from '../../shared/services/auth-fetch'; interface AgentPrompt { role: string; @@ -184,9 +185,7 @@ Return ONLY valid JSON: fetch('/api/rag-analytics/playground/analyze', { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ text: this.playgroundText }) }) .then(response => { diff --git a/src/app/analytics/ai-playground/ai-playground.component.ts b/src/app/analytics/ai-playground/ai-playground.component.ts index 9dc3818..53ccebd 100644 --- a/src/app/analytics/ai-playground/ai-playground.component.ts +++ b/src/app/analytics/ai-playground/ai-playground.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; import { environment } from '../../../environments/environment'; +import { authHeaders } from '../../shared/services/auth-fetch'; interface PipelineStage { id: string; @@ -146,7 +147,7 @@ export class AiPlaygroundComponent { fetch(`${environment.SERVER_URL}/rag-analytics/playground/generate-prompt`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ description: stage.userDescription }) }) .then(res => res.json()) @@ -166,7 +167,7 @@ export class AiPlaygroundComponent { // Stage Library fetchSavedStages() { - fetch(`${environment.SERVER_URL}/rag-analytics/stages`) + fetch(`${environment.SERVER_URL}/rag-analytics/stages`, { headers: authHeaders() }) .then(res => res.json()) .then(data => { this.savedStages = Array.isArray(data) ? data : []; }) .catch(() => { this.savedStages = []; }); @@ -175,7 +176,7 @@ export class AiPlaygroundComponent { saveStageToLibrary(stage: PipelineStage) { fetch(`${environment.SERVER_URL}/rag-analytics/stages`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ stage_id: stage.id, name: stage.name, @@ -208,7 +209,7 @@ export class AiPlaygroundComponent { } deleteSavedStage(stageId: string) { - fetch(`${environment.SERVER_URL}/rag-analytics/stages/${stageId}`, { method: 'DELETE' }) + fetch(`${environment.SERVER_URL}/rag-analytics/stages/${stageId}`, { method: 'DELETE', headers: authHeaders() }) .then(() => this.fetchSavedStages()) .catch(err => console.error('Failed to delete stage:', err)); } @@ -220,7 +221,7 @@ export class AiPlaygroundComponent { // Generate prompt from description fetch(`${environment.SERVER_URL}/rag-analytics/playground/generate-prompt`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ description: this.newStageDescription }) }) .then(res => res.json()) @@ -229,7 +230,7 @@ export class AiPlaygroundComponent { // Generate examples return fetch(`${environment.SERVER_URL}/rag-analytics/stages/generate-examples`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ system_prompt: systemPrompt, user_description: this.newStageDescription }) }) .then(res => res.json()) @@ -239,7 +240,7 @@ export class AiPlaygroundComponent { // Save to DB return fetch(`${environment.SERVER_URL}/rag-analytics/stages`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ stage_id: stageId, name, @@ -268,7 +269,7 @@ export class AiPlaygroundComponent { generateExamples(stage: PipelineStage) { fetch(`${environment.SERVER_URL}/rag-analytics/stages/generate-examples`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ system_prompt: stage.systemPrompt, user_description: stage.userDescription }) }) .then(res => res.json()) @@ -284,7 +285,7 @@ export class AiPlaygroundComponent { // Pipeline Templates fetchSavedPipelines() { - fetch(`${environment.SERVER_URL}/rag-analytics/pipelines`) + fetch(`${environment.SERVER_URL}/rag-analytics/pipelines`, { headers: authHeaders() }) .then(res => res.json()) .then(data => { this.savedPipelines = Array.isArray(data) ? data : []; }) .catch(() => { this.savedPipelines = []; }); @@ -300,7 +301,7 @@ export class AiPlaygroundComponent { fetch(`${environment.SERVER_URL}/rag-analytics/pipelines`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ template_id: templateId, name: this.savePipelineName, @@ -330,7 +331,7 @@ export class AiPlaygroundComponent { } deleteSavedPipeline(templateId: string) { - fetch(`${environment.SERVER_URL}/rag-analytics/pipelines/${templateId}`, { method: 'DELETE' }) + fetch(`${environment.SERVER_URL}/rag-analytics/pipelines/${templateId}`, { method: 'DELETE', headers: authHeaders() }) .then(() => this.fetchSavedPipelines()) .catch(err => console.error('Failed to delete pipeline:', err)); } @@ -358,7 +359,7 @@ export class AiPlaygroundComponent { fetch(`${environment.SERVER_URL}/rag-analytics/playground/execute-stage`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ type: stage.type, systemPrompt: stage.systemPrompt, @@ -449,7 +450,7 @@ export class AiPlaygroundComponent { const apiUrl = `${environment.SERVER_URL}/rag-analytics/playground/execute-pipeline`; - fetch(apiUrl, { method: 'POST', body: formData }) + fetch(apiUrl, { method: 'POST', headers: authHeaders(), body: formData }) .then(response => { const reader = response.body!.getReader(); const decoder = new TextDecoder(); diff --git a/src/app/home/home/home.component.ts b/src/app/home/home/home.component.ts index cc45c2f..45287dd 100644 --- a/src/app/home/home/home.component.ts +++ b/src/app/home/home/home.component.ts @@ -15,6 +15,7 @@ import { Title } from '@angular/platform-browser'; import { GoogleAnalyticsService } from 'ngx-google-analytics'; import { environment } from '../../../environments/environment'; import { Section508Clause, resolveClause, clauseToPlainText } from '../../shared/section508-clause'; +import { authHeaders } from '../../shared/services/auth-fetch'; @Component({ selector: 'app-home', @@ -376,7 +377,7 @@ export class HomeComponent try { const response = await fetch(`${environment.SERVER_URL}/rag-analytics/playground/package-synthesis`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ summaries, file_count: fileNames.length, diff --git a/src/app/shared/services/auth-fetch.ts b/src/app/shared/services/auth-fetch.ts new file mode 100644 index 0000000..1575c2c --- /dev/null +++ b/src/app/shared/services/auth-fetch.ts @@ -0,0 +1,19 @@ +/** + * Authorization headers for raw fetch() calls. + * + * Angular's HttpClient gets its bearer token from TokenInterceptor, but raw + * fetch() bypasses the interceptor entirely. Any fetch() to an authenticated + * SRT endpoint has to attach the header itself, and this is the one place that + * knows how. + * + * Prefer HttpClient where practical. fetch() is used in a few places for + * streaming responses and multipart uploads, which is where this helper applies. + * + * @param extra additional headers to merge, for example Content-Type. Omit + * Content-Type entirely when sending FormData so the browser can + * set the multipart boundary itself. + */ +export function authHeaders (extra: Record = {}): Record { + const token = localStorage.getItem('token') + return token ? { ...extra, Authorization: `Bearer ${token}` } : { ...extra } +}