Files
supabase/apps/studio/lib/api/status-page.ts
T
CharisandClaude Code b2cf3693dd feat(studio): /api/status-page endpoint backed by incident.io Widget API (#50931)
## Summary

* Adds `/api/status-page` (Next route + TanStack wrapper), backed by the
[incident.io](<http://incident.io>) Widget API, annotating each item
with `visible`, `show_banner`, and (for scheduled maintenances)
`banner_lead_days`.
* Deployment-mode visibility is driven by a new
`status_page:visibility_field_ids` custom-content key.
* Widget array parsing is fault-tolerant: a malformed item in one array
is dropped and logged rather than failing the whole response, so one bad
item can't hide a real ongoing incident.
* 429s from [incident.io](<http://incident.io>) are retried with
equal-jitter exponential backoff, respecting `Retry-After`, up to 2
retries.
* Nothing consumes this endpoint yet — it replaces no existing behavior
and changes nothing user-visible. Later PRs (this is PR 1 of a stack)
wire up consumers behind the `incidentIoStatusPage` ConfigCat flag.

Part of
[FE-4057](https://linear.app/supabase/issue/FE-4057/frontend-bannerbot-reconfigured)
— see Linear for full design context.

## Test plan

- [X] `pnpm --filter studio run typecheck`
- [X] `pnpm --filter studio run lint:ratchet`
- [X] `pnpm knip --workspace apps/studio`
- [X] `pnpm test:prettier`
- [X] `pnpm --filter studio exec vitest run status-page` — 44 tests
passing, including a regression test built from a real production
[incident.io](<http://incident.io>) payload that initially failed to
parse, and a compile-time type-safety regression test for the
array-parsing helper

Co-authored-by: Claude Code
[charis@supabase.io](<mailto:charis@supabase.io>)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a status page that displays ongoing incidents and maintenance,
with visibility and banner settings based on linked incident details.
* Status page data is available through a new API endpoint, with caching
for successful responses and degraded results.
* **Bug Fixes**
* Status page data can still display when some linked incident details
are unavailable; affected results are marked as degraded.
* Improved handling of invalid widget entries so they don’t prevent
valid items from being processed.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Code <charis@supabase.io>
2026-09-28 11:36:29 -04:00

224 lines
6.7 KiB
TypeScript

import { z } from 'zod'
import customContentRaw from '@/hooks/custom-content/custom-content.json'
import { InternalServerError } from '@/lib/api/apiHelpers'
import {
annotateWidget,
mergeLinkedFlags,
type ResponseIncident,
type ResponseIncidentFlags,
} from '@/lib/api/status-page.utils'
import {
WidgetResponseSchema,
type StatusPageResponse,
type WidgetResponse,
} from '@/lib/status-page/status-page.schema'
const HIDE_BANNER_FIELD_ID = '01M3D30MY0GQ1284ERHHV4DFAT'
const BANNER_LEAD_DAYS_FIELD_ID = '01M3D332BQVSDE4ZGK86X6X69V'
const WIDGET_TIMEOUT_MS = 10_000
const LINK_TIMEOUT_MS = 5_000
const INCIDENT_TIMEOUT_MS = 5_000
const MAX_RETRIES = 2
export type StatusPageResult = { data: StatusPageResponse; isDegraded: boolean }
const LinkedResponseIncidentsSchema = z.object({
incidents: z.array(z.object({ id: z.string(), linked_at: z.string() })),
})
const CustomFieldValueSchema = z.object({
value_option: z.object({ value: z.string() }).nullish(),
value_text: z.string().nullish(),
value_numeric: z.string().nullish(),
})
const ResponseIncidentSchema = z.object({
incident: z.object({
id: z.string(),
custom_field_entries: z.array(
z.object({
custom_field: z.object({ id: z.string() }),
values: z.array(CustomFieldValueSchema),
})
),
}),
})
function parseRetryAfterMs(header: string | null): number | undefined {
if (!header) return undefined
const seconds = Number(header)
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
const dateMs = Date.parse(header)
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now())
return undefined
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function backoffWithJitter(attempt: number): number {
const base = 1000 * 2 ** attempt
return base / 2 + Math.random() * (base / 2)
}
// Shared by all three incident.io calls: on a 429, wait for the Retry-After hint (or an
// exponential fallback) and retry, up to MAX_RETRIES times, then return the still-429 response
// to the caller.
async function fetchWithRetry(
url: string,
init: RequestInit,
timeoutMs: number
): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
if (response.status !== 429 || attempt >= MAX_RETRIES) {
return response
}
const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After'))
await sleep(Math.max(retryAfterMs ?? 0, backoffWithJitter(attempt)))
}
}
async function fetchWidget(url: string): Promise<WidgetResponse> {
const response = await fetchWithRetry(url, { cache: 'no-store' }, WIDGET_TIMEOUT_MS)
if (!response.ok) {
const retryAfter = response.headers.get('Retry-After') ?? undefined
const body = await response.text()
throw new InternalServerError(`incident.io Widget API responded with ${response.status}`, {
status: response.status,
body,
...(retryAfter !== undefined && { retryAfter }),
})
}
const json = await response.json()
const result = WidgetResponseSchema.safeParse(json)
if (!result.success) {
throw new InternalServerError('incident.io Widget API response did not match expected schema', {
issues: result.error.issues,
})
}
return result.data
}
async function fetchLinkedResponseIncidentIds(
apiKey: string,
statusPageId: string,
itemId: string
): Promise<Array<string>> {
const response = await fetchWithRetry(
`https://api.incident.io/v1/status-pages/${statusPageId}/incidents/${itemId}/response-incidents`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
LINK_TIMEOUT_MS
)
if (response.status === 404) return []
if (!response.ok) {
throw new Error(`incident.io response-incidents lookup responded with ${response.status}`)
}
const json = await response.json()
const result = LinkedResponseIncidentsSchema.parse(json)
return result.incidents.map((incident) => incident.id)
}
async function fetchResponseIncident(apiKey: string, id: string): Promise<ResponseIncident> {
const response = await fetchWithRetry(
`https://api.incident.io/v2/incidents/${id}`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
INCIDENT_TIMEOUT_MS
)
if (!response.ok) {
throw new Error(`incident.io incident lookup responded with ${response.status}`)
}
const json = await response.json()
return ResponseIncidentSchema.parse(json).incident
}
export async function getStatusPage(): Promise<StatusPageResult> {
const widgetUrl = process.env.INCIDENT_IO_WIDGET_URL
if (!widgetUrl) {
throw new InternalServerError('INCIDENT_IO_WIDGET_URL is not set')
}
const widget = await fetchWidget(widgetUrl)
const modeFieldIds = customContentRaw['status_page:visibility_field_ids'] ?? ['']
const apiKey = process.env.INCIDENT_IO_API_KEY
const statusPageId = process.env.INCIDENT_IO_STATUS_PAGE_ID
if (!apiKey || !statusPageId) {
console.error('INCIDENT_IO_API_KEY or INCIDENT_IO_STATUS_PAGE_ID is not set')
return { data: annotateWidget(widget, new Map(), modeFieldIds), isDegraded: true }
}
let isDegraded = false
const allItems = [
...widget.ongoing_incidents,
...widget.in_progress_maintenances,
...widget.scheduled_maintenances,
]
const linkResults = await Promise.allSettled(
allItems.map((item) => fetchLinkedResponseIncidentIds(apiKey, statusPageId, item.id))
)
const linkedIdsByItemId = new Map<string, Array<string>>()
linkResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
linkedIdsByItemId.set(allItems[index].id, result.value)
} else {
isDegraded = true
}
})
const uniqueLinkedIds = Array.from(new Set(Array.from(linkedIdsByItemId.values()).flat()))
const incidentResults = await Promise.allSettled(
uniqueLinkedIds.map((id) => fetchResponseIncident(apiKey, id))
)
const incidentsById = new Map<string, ResponseIncident>()
incidentResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
incidentsById.set(uniqueLinkedIds[index], result.value)
} else {
isDegraded = true
}
})
const flagsById = new Map<string, ResponseIncidentFlags>()
for (const item of allItems) {
const linkedIncidents = (linkedIdsByItemId.get(item.id) ?? [])
.map((id) => incidentsById.get(id))
.filter((incident): incident is ResponseIncident => incident !== undefined)
if (linkedIncidents.length > 0) {
flagsById.set(
item.id,
mergeLinkedFlags(linkedIncidents, modeFieldIds, {
hideBannerFieldId: HIDE_BANNER_FIELD_ID,
leadDaysFieldId: BANNER_LEAD_DAYS_FIELD_ID,
})
)
}
}
return { data: annotateWidget(widget, flagsById, modeFieldIds), isDegraded }
}