fix(generic): browser UA default + per-source custom headers + 403 retry (#85)

The MeshAI/1.0 UA intermittently trips WAFs (Idaho Power's Azure Front Door
403s it ~2/30; a browser UA gets 200 every time). Default the adapter +
preview to a browser User-Agent, retry once on 403/429, and add optional
per-source custom headers (UA/auth) editable in the GUI. Makes WAF'd and
keyed feeds pollable.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-07 02:24:27 -06:00 committed by GitHub
commit 1a1aef2e6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 310 additions and 50 deletions

View file

@ -31,6 +31,7 @@ function blankSource(n: number): GenericSource {
field_mappings: [],
summary_template: '',
emoji: '',
headers: {},
}
}
@ -176,12 +177,43 @@ export default function GenericSourcesEditor() {
)
}
// Headers are stored as a Record<string,string> on the source; edit them as
// an ordered list of key/value rows (mirrors the field_mappings editor),
// rebuilding the record on each change.
const headerEntries = (s: GenericSource): [string, string][] =>
Object.entries(s.headers ?? {})
const setHeaderEntries = (idx: number, entries: [string, string][]) => {
const obj: Record<string, string> = {}
for (const [k, v] of entries) obj[k] = v
update(idx, { headers: obj })
}
const addHeader = (idx: number) => {
if (!sources) return
setHeaderEntries(idx, [...headerEntries(sources[idx]), ['', '']])
}
const updateHeader = (idx: number, hIdx: number, key: string, value: string) => {
if (!sources) return
const entries = headerEntries(sources[idx])
entries[hIdx] = [key, value]
setHeaderEntries(idx, entries)
}
const removeHeader = (idx: number, hIdx: number) => {
if (!sources) return
const entries = headerEntries(sources[idx])
entries.splice(hIdx, 1)
setHeaderEntries(idx, entries)
}
const runPreview = async (idx: number) => {
if (!sources) return
const src = sources[idx]
setPreviewing((p) => ({ ...p, [idx]: true }))
try {
const res = await previewGenericSource(src.url, src.items_path)
const res = await previewGenericSource(src.url, src.items_path, src.headers)
setPreviews((p) => ({ ...p, [idx]: res }))
} catch (e) {
setPreviews((p) => ({
@ -391,6 +423,55 @@ export default function GenericSourcesEditor() {
/>
</div>
{/* Headers (optional) */}
<div className="border border-border p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666]">
Headers (optional)
</div>
<button
onClick={() => addHeader(i)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-accent/20 hover:bg-accent/30 text-accent border border-accent/30"
>
<Plus size={12} /> Add header
</button>
</div>
<p className="text-xs text-[#555]">
Custom request headers, e.g. <code>User-Agent</code> or{' '}
<code>Authorization</code>. Leave empty for the default browser UA.
</p>
{Object.entries(src.headers ?? {}).length > 0 && (
<div className="space-y-2">
{Object.entries(src.headers ?? {}).map(([hKey, hVal], hIdx) => (
<div key={hIdx} className="flex items-center gap-2">
<input
type="text"
value={hKey}
onChange={(e) => updateHeader(i, hIdx, e.target.value, hVal)}
placeholder="header name (e.g. Authorization)"
className="flex-1 bg-[#0d0d0d] border border-border px-2 py-1.5 text-xs font-mono text-[#e0e0e0] placeholder:text-[#555]"
/>
<span className="text-[#555] text-xs">:</span>
<input
type="text"
value={hVal}
onChange={(e) => updateHeader(i, hIdx, hKey, e.target.value)}
placeholder="value (e.g. Bearer …)"
className="flex-1 bg-[#0d0d0d] border border-border px-2 py-1.5 text-xs font-mono text-[#e0e0e0] placeholder:text-[#555]"
/>
<button
onClick={() => removeHeader(i, hIdx)}
title="Remove header"
className="flex items-center px-2 py-1.5 text-xs text-[#777] hover:text-red-400 border border-border"
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
</div>
{/* Location */}
<SectionLabel>Location GeoJSON geometry OR lat + lon paths</SectionLabel>
<div className="grid grid-cols-3 gap-2">

View file

@ -326,6 +326,9 @@ export interface GenericSource {
field_mappings: FieldMapping[]
summary_template?: string
emoji?: string
// Optional custom request headers (e.g. User-Agent override or Authorization).
// Empty/absent = the default browser UA. Sent on both poll and preview.
headers?: Record<string, string>
}
export interface GenericSourcePreview {
@ -359,12 +362,13 @@ export async function saveGenericSources(
export async function previewGenericSource(
url: string,
items_path?: string
items_path?: string,
headers?: Record<string, string>
): Promise<GenericSourcePreview> {
const response = await fetch('/api/generic-sources/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, items_path }),
body: JSON.stringify({ url, items_path, headers }),
})
// The endpoint never raises; it returns {ok:false,error} on failure too.
return response.json()