Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## Summary
- Describe what changed and why.

## Source curation checklist
- [ ] I ran `npm run compile:sources`
- [ ] I ran `npm run check:sources:freshness`
- [ ] I ran `npm run check:sources:hints`
- [ ] I documented replacement/removal rationale for risky or dead endpoints

## Validation
- [ ] I ran `npm run validate:feed-data`
- [ ] I ran `npm run validate:live-feed-output` (if feed output changed)
- [ ] I ran `npm test`

## Operational impact
- [ ] No user-facing behavior change
- [ ] User-facing behavior changed (describe below)

### Notes
- Add rollout, guardrail, or follow-up notes.
8 changes: 8 additions & 0 deletions .github/workflows/catalog-hygiene.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ jobs:
path: |
data/source-remediation-sweep.json
data/top-20-source-remediation.json
data/build-observability-summary.json
data/build-observability-trend.json
data/quarantined-sources.json
source-quarantine.html
if-no-files-found: warn
Expand All @@ -59,6 +61,12 @@ jobs:
if [ -f data/top-20-source-remediation.json ]; then
git add data/top-20-source-remediation.json
fi
if [ -f data/build-observability-summary.json ]; then
git add data/build-observability-summary.json
fi
if [ -f data/build-observability-trend.json ]; then
git add data/build-observability-trend.json
fi
if [ -f data/quarantined-sources.json ]; then
git add data/quarantined-sources.json
fi
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci-feed-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ jobs:
- name: Validate compiled source catalog freshness
run: npm run check:sources:freshness

- name: Generate source catalog lint hints
run: npm run check:sources:hints

- name: Validate London source health
env:
BRIALERT_SOURCE_HEALTH_SCOPE: critical
Expand Down Expand Up @@ -116,6 +119,8 @@ jobs:
cp live-alerts.json .ci-artifacts/live-alerts.json 2>/dev/null || true
cp data/source-remediation-sweep.json .ci-artifacts/source-remediation-sweep.json 2>/dev/null || true
cp data/top-20-source-remediation.json .ci-artifacts/top-20-source-remediation.json 2>/dev/null || true
cp data/build-observability-summary.json .ci-artifacts/build-observability-summary.json 2>/dev/null || true
cp data/build-observability-trend.json .ci-artifacts/build-observability-trend.json 2>/dev/null || true
node ./scripts/ci/generate-feed-smoke-summary.mjs live-alerts.json .ci-artifacts/feed-smoke-summary.json

- name: Enforce CI smoke runtime budget
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Requires Node `20.18.1` or newer.
npm ci
npm run compile:sources
npm run check:sources:freshness
npm run check:sources:hints
npm run validate:feed-data
npm run validate:source-health
npm test
Expand Down Expand Up @@ -89,7 +90,9 @@ npm run build:feeds
- If a refresh preserves prior alerts and reports `sourceCount: 0`, the app now falls back to `health.lastSuccessfulSourceCount` so the hero source count does not stick at zero.
- Source catalog can be managed in sharded files under `data/sources/<region>/<lane>.json`; `npm run compile:sources` rebuilds `data/sources.json`.
- Build runs now emit `data/source-remediation-sweep.json` and `data/top-20-source-remediation.json` to prioritize dead/moved URLs and replacement actions.
- Build runs now emit `data/build-observability-summary.json` and `data/build-observability-trend.json` for structured diagnostics and trend tracking.
- Build/runtime knobs (timeouts, retries, prefetch counts, html budget, guardrail fail behavior) are configurable through `BRIALERT_*` environment variables for CI fast-mode tuning.
- Operational recovery procedures are documented in `docs/runbooks/operations.md`.

## Source catalog contribution rules

Expand Down
25 changes: 25 additions & 0 deletions api/_lib/admin-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,37 @@ export function applyCorsHeaders(request, response, methods) {
}
response.setHeader('Access-Control-Allow-Methods', methods);
response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
response.setHeader('Access-Control-Max-Age', '600');
if (request?.method === 'OPTIONS') {
return !requestOrigin || allowedOrigins.has(requestOrigin);
}
return true;
}

export function logAdminAudit(event, details = {}) {
const payload = {
event: String(event || 'unknown'),
at: new Date().toISOString(),
...details
};
console.info(`[admin-audit] ${JSON.stringify(payload)}`);
}

export function ensureMutatingRequestIsTrusted(request, response) {
const allowedOrigins = new Set(getAllowedOrigins());
const origin = normaliseOrigin(request?.headers?.origin);
const referer = normaliseOrigin(request?.headers?.referer);
if (origin && allowedOrigins.has(origin)) return true;
if (referer && allowedOrigins.has(referer)) return true;

response.status(403).json({
ok: false,
error: 'forbidden-origin',
message: 'Origin/referer is not authorized for state-changing admin actions.'
});
return false;
}

export function readAdminSession(request) {
const cookies = parseCookies(request?.headers?.cookie);
const payload = verifySignedToken(cookies[SESSION_COOKIE_NAME]);
Expand Down
5 changes: 5 additions & 0 deletions api/auth/github/callback.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
clearOauthStateCookie,
getAuthRedirectConfig,
getOAuthRedirectUri,
logAdminAudit,
readOauthState,
setAdminSessionCookie
} from '../../_lib/admin-session.js';
Expand Down Expand Up @@ -77,8 +78,12 @@ export default async function handler(request, response) {

setAdminSessionCookie(request, response, user);
clearOauthStateCookie(request, response);
logAdminAudit('auth.oauth.callback.success', { actor: user.login });
return redirect(response, appendQueryParams(returnTo, { auth: 'ok' }));
} catch (error) {
logAdminAudit('auth.oauth.callback.failed', {
message: error instanceof Error ? error.message : String(error)
});
clearAdminSessionCookie(request, response);
clearOauthStateCookie(request, response);
return redirect(response, failRedirectTarget(mapErrorCode(error)));
Expand Down
5 changes: 5 additions & 0 deletions api/auth/github/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ApiError } from '../../_lib/github-persistence.js';
import {
createOauthState,
getOAuthRedirectUri,
logAdminAudit,
normalizeReturnTo,
setOauthStateCookie
} from '../../_lib/admin-session.js';
Expand Down Expand Up @@ -53,11 +54,15 @@ export default async function handler(request, response) {
authUrl.searchParams.set('scope', 'read:user read:org');
authUrl.searchParams.set('state', nonce);
authUrl.searchParams.set('allow_signup', 'false');
logAdminAudit('auth.oauth.start', { returnTo });

response.statusCode = 302;
response.setHeader('Location', authUrl.toString());
return response.end();
} catch (error) {
logAdminAudit('auth.oauth.start.failed', {
message: error instanceof Error ? error.message : String(error)
});
return sendError(response, error);
}
}
10 changes: 9 additions & 1 deletion api/auth/logout.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
applyCorsHeaders,
clearAdminSessionCookie
clearAdminSessionCookie,
ensureMutatingRequestIsTrusted,
logAdminAudit,
readAdminSession
} from '../_lib/admin-session.js';

export default async function handler(request, response) {
Expand All @@ -16,7 +19,12 @@ export default async function handler(request, response) {
message: 'Only POST is supported.'
});
}
if (!ensureMutatingRequestIsTrusted(request, response)) {
return response;
}

const session = readAdminSession(request);
clearAdminSessionCookie(request, response);
logAdminAudit('auth.logout', { actor: session?.login || 'anonymous' });
return response.status(200).json({ ok: true });
}
2 changes: 2 additions & 0 deletions api/auth/session.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
applyCorsHeaders,
getAllowedOrigins,
logAdminAudit,
readAdminSession
} from '../_lib/admin-session.js';

Expand Down Expand Up @@ -35,6 +36,7 @@ export default async function handler(request, response) {
const session = readAdminSession(request);
const originHint = request.headers.origin;
if (!session) {
logAdminAudit('auth.session.unauthenticated', {});
return response.status(200).json({
ok: true,
authenticated: false,
Expand Down
10 changes: 8 additions & 2 deletions api/quarantined-sources.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiError, loadJsonFile } from './_lib/github-persistence.js';
import { applyCorsHeaders, requireAdminSession } from './_lib/admin-session.js';
import { applyCorsHeaders, logAdminAudit, requireAdminSession } from './_lib/admin-session.js';

function sendError(response, error) {
const status = error instanceof ApiError ? error.status : 500;
Expand Down Expand Up @@ -50,7 +50,9 @@ export default async function handler(request, response) {
message: 'Only GET is supported.'
});
}
if (!requireAdminSession(request, response)) {
const session = requireAdminSession(request, response);
if (!session) {
logAdminAudit('quarantined-sources.unauthenticated', {});
return response;
}

Expand All @@ -77,6 +79,10 @@ export default async function handler(request, response) {
sources
});
} catch (error) {
logAdminAudit('quarantined-sources.failed', {
actor: session.login,
message: error instanceof Error ? error.message : String(error)
});
return sendError(response, error);
}
}
24 changes: 22 additions & 2 deletions api/restore-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
normaliseEndpoint,
validateAbsoluteHttpUrl
} from './_lib/github-persistence.js';
import { applyCorsHeaders, requireAdminSession } from './_lib/admin-session.js';
import {
applyCorsHeaders,
ensureMutatingRequestIsTrusted,
logAdminAudit,
requireAdminSession
} from './_lib/admin-session.js';

const QUARANTINE_ONLY_FIELDS = new Set([
'status',
Expand Down Expand Up @@ -135,7 +140,12 @@ export default async function handler(request, response) {
message: 'Only POST is supported.'
});
}
if (!requireAdminSession(request, response)) {
const session = requireAdminSession(request, response);
if (!session) {
return response;
}
if (!ensureMutatingRequestIsTrusted(request, response)) {
logAdminAudit('restore-source.forbidden-origin', { actor: session.login });
return response;
}

Expand Down Expand Up @@ -217,12 +227,22 @@ export default async function handler(request, response) {
`Restore quarantined source ${sourceId}`
);

logAdminAudit('restore-source.success', {
actor: session.login,
sourceId,
replacementUrl
});

return response.status(200).json({
ok: true,
restoredSource,
message: 'Source restored successfully.'
});
} catch (error) {
logAdminAudit('restore-source.failed', {
actor: session.login,
message: error instanceof Error ? error.message : String(error)
});
return sendError(response, error);
}
}
26 changes: 26 additions & 0 deletions app/boot/actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,32 @@ export function setSearchQuery(state, nextQuery) {
state.searchQuery = String(nextQuery || '');
}

export function setActiveLane(state, nextLane) {
state.activeLane = String(nextLane || 'all');
state.feedVisibleCount = INITIAL_RESPONDER_VISIBLE;
state.supportingVisibleCount = INITIAL_SUPPORTING_VISIBLE;
}

export function setActiveSeverityThreshold(state, nextThreshold) {
state.activeSeverityThreshold = String(nextThreshold || 'all');
state.feedVisibleCount = INITIAL_RESPONDER_VISIBLE;
state.supportingVisibleCount = INITIAL_SUPPORTING_VISIBLE;
}

export function addMutedSource(state, source) {
const value = String(source || '').trim().toLowerCase();
if (!value) return false;
if (state.mutedSources.has(value)) return false;
state.mutedSources.add(value);
return true;
}

export function removeMutedSource(state, source) {
const value = String(source || '').trim().toLowerCase();
if (!value) return false;
return state.mutedSources.delete(value);
}

export function toggleWatchedAlert(state, alertId) {
if (!alertId) return;
if (state.watched.has(alertId)) state.watched.delete(alertId);
Expand Down
5 changes: 5 additions & 0 deletions app/boot/elements.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export function createElements() {
mapEmptyState: document.getElementById('map-empty-state'),
mapModeTabs: document.getElementById('map-mode-tabs'),
filters: document.getElementById('filters'),
laneFilter: document.getElementById('lane-filter'),
severityFilter: document.getElementById('severity-filter'),
sourceMuteForm: document.getElementById('source-mute-form'),
sourceMuteInput: document.getElementById('source-mute-input'),
sourceMuteList: document.getElementById('source-mute-list'),
tabbar: document.getElementById('tabbar'),
briefingModePanel: document.getElementById('briefing-mode-panel'),
briefingModeTitle: document.getElementById('briefing-mode-title'),
Expand Down
38 changes: 38 additions & 0 deletions app/boot/events.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export function bindEvents({
notesStorageKey,
sourceRequestsStorageKey,
watchedStorageKey,
mutedSourcesStorageKey,
sourceRequestApiUrl,
severityThresholdStorageKey,
actions,
rendering,
setActiveTab,
Expand All @@ -37,6 +39,42 @@ export function bindEvents({
rendering.renderAll();
});

elements.laneFilter?.addEventListener('change', (event) => {
actions.setActiveLane(state, String(event.target?.value || 'all'));
rendering.invalidateDerivedView();
rendering.renderAll();
});

elements.severityFilter?.addEventListener('change', (event) => {
const value = String(event.target?.value || 'all');
actions.setActiveSeverityThreshold(state, value);
if (typeof localStorage !== 'undefined') {
localStorage.setItem(severityThresholdStorageKey, value);
}
rendering.invalidateDerivedView();
rendering.renderAll();
});

elements.sourceMuteForm?.addEventListener('submit', (event) => {
event.preventDefault();
const value = String(elements.sourceMuteInput?.value || '').trim();
if (!actions.addMutedSource(state, value)) return;
saveSet(mutedSourcesStorageKey, state.mutedSources);
if (elements.sourceMuteInput) elements.sourceMuteInput.value = '';
rendering.invalidateDerivedView();
rendering.renderAll();
});

elements.sourceMuteList?.addEventListener('click', (event) => {
const button = event.target.closest('[data-remove-muted-source]');
if (!button) return;
const source = button.getAttribute('data-remove-muted-source');
if (!actions.removeMutedSource(state, source)) return;
saveSet(mutedSourcesStorageKey, state.mutedSources);
rendering.invalidateDerivedView();
rendering.renderAll();
});

elements.feedLoadMore?.addEventListener('click', () => {
actions.incrementResponderVisible(state);
rendering.renderAll();
Expand Down
Loading