Codync
A free, open-source 1:1 alternative to Grok Bot and Muse. Message Claude Code, Codex, Cursor and 40+ coding agents as persistent bots from your iPhone, Mac or Linux desktop. Group chats, threads, appr
Is AWS, Azure, Google Cloud or Oracle Cloud down? Live status, active outages and incidents by region and service, auto-refreshed every minute.
Developer toolsTypeScript · ShellGitHub @jxmanOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
🌐 Real-time cloud provider status dashboard for AWS, Azure, GCP, and OCI — unified view with incident tracking, region-level health, and auto-refresh
Taken from the repository README
Loading the full README.
Read the full README ↗Real-time operational status dashboard for AWS, Azure, OCI, and GCP in a single unified view. Built for cloud engineers and anyone who needs a quick read on cloud provider health.
What it does
/aws, /azure, /gcp and /oci filter the dashboard to one provider, with a filter bar to switch between them; each page has its own title and text for search engines.The dashboard is a static React SPA. Three of the four providers are fetched straight from the browser; everything else — the Azure proxy, sign-ups, change detection, AI briefs, and admin — runs as Vercel Functions.
flowchart TB
browser["Browser<br/>React SPA"]
eventbridge["AWS EventBridge<br/>every 5 min"]
subgraph feeds["Provider status feeds"]
direct["AWS · OCI · GCP<br/>CORS-permissive"]
azurefeed["Azure<br/>CORS-blocked"]
end
fns["Vercel Functions<br/>/api/*"]
subgraph stores["Storage"]
redis[("Upstash Redis<br/>snapshots · settings · limits")]
neon[("Neon Postgres<br/>subscribers · briefs · history")]
blob[("Vercel Blob<br/>PDF briefs")]
end
subgraph services["External services"]
bedrock["AWS Bedrock<br/>Claude Sonnet 4.6"]
resend["Resend<br/>email to subscribers"]
end
browser -->|"direct fetch"| direct
browser -->|"Azure proxy, sign-up,<br/>briefs, history, admin"| fns
eventbridge -->|"cron trigger"| fns
fns -->|"poll"| feeds
fns --> stores
fns --> services
The rest of this README walks through each part: the dashboard data path, email alerts, the AI Insight pipeline, and admin.
Vercel's Hobby plan caps a deployment at 12 functions, so several routes
share one file (a vercel.json rewrite maps the bare path to a bracket
file, which the handler reads as "no id/action given").
| Function | Routes | Access | Purpose |
|---|---|---|---|
api/status/azure.ts | GET /api/status/azure | Public | Azure feed proxy, normalized to the unified schema, plus 24h of resolved incidents from Redis |
api/subscribe/[action].ts | POST /api/subscribe, /confirm, /manage, /unsubscribe | Public / token | Sign-up, confirmation, manage, unsubscribe |
api/auth/[action].ts | /api/auth/authorize, /callback, /signout | Public | Sign in with Vercel (PKCE) and admin session cookie |
api/admin/subscribers/[id].ts | GET /api/admin/subscribers, PATCH/DELETE /api/admin/subscribers/:id | Admin | Subscriber list, filters, CSV, manual actions |
api/admin/test-email.ts | POST /api/admin/test-email | Admin | Send any email template to a confirmed subscriber |
api/admin/analysis-admin.ts | /api/admin/analysis-admin?resource=… | Admin | AI run history, retry, delete, cleanup, settings, PDF redirect |
api/analysis/run.ts | POST /api/analysis/run | CRON_SECRET | Generate an AI brief for one incident |
api/analysis/latest.ts | GET /api/analysis/latest | Public | Latest brief pointer, or immutable brief content by id |
api/incidents/history.ts | GET /api/incidents/history | Public | Incidents resolved 24h–90d ago |
api/cron/check-status.ts | /api/cron/check-status | CRON_SECRET | Change detection, email dispatch, AI and history triggers |
api/cron/cleanup.ts | /api/cron/cleanup | CRON_SECRET | Daily retention purge |
| Layer | Technology |
|---|---|
| Frontend | React 18 + Vite 7 |
| Language | TypeScript 5.6 |
| Styling | Tailwind CSS 3 (dark mode via class strategy, plus a system-preference mode and an opt-in Terminal theme) |
| XML parsing | fast-xml-parser 5 (browser + serverless) |
| Serverless | Vercel Functions (Node.js) |
| Database | Neon Postgres (Vercel Marketplace) |
| Cache / state | Upstash Redis (Vercel Marketplace) |
Resend, from alerts.synepho.com | |
| AI / LLM | AWS Bedrock — Claude Sonnet 4.6 (us.anthropic.claude-sonnet-4-6), via Vercel OIDC → AWS federation |
| PDF generation | @react-pdf/renderer, stored in Vercel Blob |
| Admin auth | Sign in with Vercel (OAuth, PKCE) |
| Scheduling | AWS EventBridge (every 5 min) + Vercel cron (daily) |
| Analytics | Vercel Web Analytics, Speed Insights, GA4 |
| Provider | Dashboard | Data URL | Format | CORS | Fetched via |
|---|---|---|---|---|---|
| AWS | https://status.aws.amazon.com/ | .../rss/all.rss | RSS/XML | ✅ Direct | Browser |
| Azure | https://azure.status.microsoft/ | rssfeed.azure.status.microsoft/... | RSS/XML | ❌ Blocked | /api/status/azure |
| OCI | https://ocistatus.oraclecloud.com/ | .../api/v2/status.json + .../api/v2/incident-summary.rss | JSON + RSS | ✅ Direct | Browser |
| GCP | https://status.cloud.google.com/ | .../incidents.json | JSON | ✅ Direct | Browser |
Panels are always shown in this order: AWS → Azure → OCI → GCP.
Coverage notes
all.rss covers only incidents AWS publishes publicly (significant or widespread events). Minor single-service degradations appear only in per-service feeds. The feed emits one item per update, so items are deduplicated by GUID.<category> elements (or the title, then the description, as fallbacks) and are rolled up into the same region × service breakdown as the other providers, matched against a top-10 service list with anything else under "Multiple Services *". Full per-account detail would need the authenticated Azure Service Health API.status.json is only a bare {indicator, description} summary. incident-summary.rss supplies one stable-guid item per incident, with region and service parsed from its "{service} | {region} | {ref}" title.incidents.json is structured; services are matched to a top-10 list by stable productId (verify with npm run verify:gcp).flowchart LR
subgraph fetchers["Fetchers (src/fetchers)"]
aws["awsFetcher<br/>all.rss → dedupe"]
azure["azureFetcher<br/>/api/status/azure"]
oci["ociFetcher<br/>status.json + RSS"]
gcp["gcpFetcher<br/>incidents.json"]
end
timeout["withTimeout()<br/>12s each"]
hook["useStatusPolling<br/>per-provider state map"]
cache[("localStorage<br/>60s TTL")]
subgraph ui["UI"]
header["StatusHeader"]
grid["ProviderGrid<br/>ProviderPanel · RegionTable"]
incidents["IncidentList<br/>IncidentCard · AI Insight"]
past["PastIncidents"]
end
aws & azure & oci & gcp --> timeout --> hook
hook <--> cache
hook --> header & grid & incidents
past -->|"on expand"| historyApi["/api/incidents/history"]
Every fetcher returns the same unified ProviderStatus schema
(src/types/status.ts). Each provider settles independently: a card renders
the moment its own fetch resolves, and a slow or hung provider only delays
its own card (skeleton, then an error card after 12s). If all four fail —
e.g. the network is down — the last good data stays on screen.
| Behavior | Detail |
|---|---|
| Auto-refresh interval | 60 seconds; pauses while the tab is hidden, fetches immediately on focus |
| Manual refresh cooldown | 60 seconds (starts after fetch completes) |
| Client cache (localStorage) | 60s TTL — hydrated on page load |
| Azure proxy cache | s-maxage=300, stale-while-revalidate=60 |
| AI brief cache | Pointer s-maxage=300; content s-maxage=31536000, immutable once finalized — see Reading a brief back |
| Past incidents cache | s-maxage=3600, stale-while-revalidate=86400 |
| Offline behavior | Auto-refresh pauses; banner shown; cached data displayed |
| Stale data indicator | Yellow banner if the last fetch failed but cached data is available |
| Stale app bundle | useVersionCheck polls /version.json every 5 min and on tab focus; a banner offers Reload when a new deploy is live |
/aws, /azure, /gcp and /oci filter the dashboard to one provider, and
double as landing pages for searches like "is AWS down". / shows all four. A
filter bar (All providers · AWS · Azure · OCI · GCP) switches between them; each
entry is a real link with a live status dot, so a filtered page still shows when
another provider is having problems.
On a provider page the card, summary banner, active and recently resolved
incidents, and past incidents (/api/incidents/history?provider=) are all
limited to that provider, and "Get alerts" pre-ticks it. Clicking the filter
swaps the URL with pushState and updates the page title without a reload;
the back button works, and all four providers keep polling either way.
flowchart LR
data["src/seo/providerPages.ts<br/>title, description, crawler text"] --> plugin
built["dist/index.html<br/>(built homepage)"] --> plugin["vite.config.ts<br/>providerPagesPlugin"]
plugin --> pages["dist/aws.html, azure.html,<br/>gcp.html, oci.html"]
pages --> rewrite["vercel.json rewrite<br/>/aws → /aws.html"]
rewrite --> app["Same JS bundle<br/>getFocusProvider() reads the path,<br/>ProviderFilter switches it"]
index.html for each provider and swaps in that page's title, description, canonical URL, Open Graph/Twitter tags and the <main> fallback that crawlers without JavaScript read. The build fails if any of those tags can't be found, so a page can't ship with the homepage's canonical URL.lastmod.vercel.json only rewrites /admin, /manage and the four provider paths; anything else returns 404 rather than the homepage./aws/ (and the other three) permanently redirect (308) to the slash-less path, so hand-typed or mis-linked URLs don't hit the 404.| Route | Purpose |
|---|---|
/ — "Get alerts" header button, the all-clear message, "Alert me about <provider>" on incident cards, or /?subscribe=1[&provider=aws] | Sign-up form: name, email, provider checkboxes, hidden honeypot. Incident cards and &provider= pre-tick that provider |
/manage?token=... | Edit providers or unsubscribe (link from the welcome email and every alert) |
sequenceDiagram
actor User
participant SPA as React SPA
participant API as /api/subscribe
participant Redis as Upstash Redis
participant DB as Neon Postgres
participant Mail as Resend
User->>SPA: Fill in sign-up form
SPA->>API: POST /api/subscribe
API->>Redis: Check per-email and daily limits
API->>DB: Upsert subscriber (pending_confirmation)
API->>Mail: Send confirmation email
Mail-->>User: Confirm link
User->>API: GET /api/subscribe/confirm?token=...
API->>DB: status = confirmed, issue manage_token
API->>Mail: Send welcome email (manage + unsubscribe links)
Abuse protection (api/_lib/signupLimits.ts):
hpField); if a bot fills it, the request gets a normal-looking success response but no email is sentOver-limit requests get a 429 with a plain-language message. A Redis error fails open, and double opt-in still applies. There is deliberately no per-IP limit: Zscaler-style corporate proxies send a whole company's traffic out through a few shared IPs.
Why not Vercel BotID (removed 2026-10-01): its browser challenge was blocked by Zscaler, so sign-ups from Zscaler-managed corporate laptops got a 403 "Request blocked". Many cloud engineers work behind proxies like this. Double opt-in already stops bots from subscribing anyone; the only remaining risk is the form being used to send email, which the limits above bound directly.
Form defaults: opened from the all-providers view (or a /?subscribe=1 link without &provider=), the form starts with "All providers" ticked; opened from a provider page or an incident card's "Alert me about …", it starts with just that provider. "All providers" and the individual boxes are exclusive but never locked: clicking a provider while All is ticked switches to that provider, and ticking All clears individual picks. "All providers" is stored as all, so it also covers any provider added later.
Re-confirmation: new sign-ups and any provider change made through the public form need a click on a confirmation link before taking effect, since that form is unauthenticated. Resubmitting an email that's still pending_confirmation reissues the same link; resubmitting an unsubscribed email starts a fresh sign-up.
Welcome email: sent once after first confirmation and carries the /manage and unsubscribe links (manage_token doesn't exist until then).
Manage: edits via /manage?token=... apply immediately — the token already proves inbox ownership.
Unsubscribe: the email link opens /manage?token=...&action=unsubscribe with a Cancel / "Yes, unsubscribe" panel rather than unsubscribing on click. That's still one step from the email (CAN-SPAM), but a link scanner prefetching the URL can't unsubscribe anyone. itself stays idempotent.
sequenceDiagram
participant EB as AWS EventBridge
participant Cron as /api/cron/check-status
participant Feeds as Provider feeds
participant Redis as Upstash Redis
participant DB as Neon Postgres
participant Mail as Resend
participant Run as /api/analysis/run
EB->>Cron: Every 5 min (Bearer CRON_SECRET)
Cron->>Feeds: Run the same four fetchers as the dashboard
Cron->>Redis: Read snapshot per provider
Note over Cron: Diff incident IDs:<br/>new, resolved, or renamed
Cron->>DB: Load confirmed subscribers for the provider
Cron->>Mail: Send outage / resolution emails
Cron->>DB: Log every attempt to notification_log
Cron->>DB: Upsert incident_history (only if something resolved)
Cron->>Redis: Write new snapshot (+ resolved:azure)
Cron--)Run: waitUntil(fetch) per changed incident — fire-and-forget
startTime. Azure has no stable incident ID, so retitling an incident changes its derived ID; these pairs are treated as one incident (no emails, AI brief re-run under the new ID) instead of a false resolved + new pair. Ambiguous matches fall through to normal handling (api/_lib/incidentRenames.ts)."all") are emailed in parallel; every attempt — success or failure — is logged to notification_log. Emails list the affected regions and link to a dashboard deep link (/?provider=…&incidentId=…) that scrolls to the incident and opens its AI Insight panel.resolved:azure and merged back into /api/status/azure for 24h.check-status every 5 minutes through an API Destination (Authorization: Bearer $CRON_SECRET). Vercel's daily cron stays wired as a free fallback. Provisioned by scripts/setup-eventbridge-cron.sh.AI-generated technical and executive briefs for each incident, built on AWS
Bedrock (Claude Sonnet 4.6), shown in the dashboard's AI Insight panel
and downloadable as branded PDFs. For a deeper walkthrough of the prompt,
how to iterate on it, and a plan for editing it from the admin UI, see
docs/PROMPT-REFINEMENT-GUIDE.md.
flowchart TB
cron["check-status.ts<br/>(every 5 min)"]
trigger{"Incident new,<br/>content changed,<br/>or resolved?"}
run["/api/analysis/run<br/>(fire-and-forget, after emails)"]
debounce{"Debounce passed?<br/>default 30 min per incident"}
prompt["Build prompt<br/>analysisPrompt.ts"]
bedrock["AWS Bedrock Converse<br/>forced tool use: emit_incident_brief"]
parse["parseStructuredBriefs()"]
row[("incident_analysis row<br/>complete or failed")]
pdf["Render PDFs<br/>@react-pdf/renderer"]
blob[("Vercel Blob")]
retry["Admin retry<br/>(bypasses debounce)"]
cron --> trigger -->|yes| run --> debounce
debounce -->|yes| prompt --> bedrock --> parse --> row
debounce -->|no| skip["Skip"]
row -->|"best effort"| pdf --> blob
blob -->|"UPDATE pdf_*_url"| row
retry --> prompt
check-status.ts hashes each active incident's status + latestUpdate + affectedServices + affectedRegions and stores the hash in the Redis snapshot. An incident triggers a brief when it's new, content_changed (same ID, different hash — catches a vendor editing text in place), or resolved.waitUntil(fetch('/api/analysis/run')) placed after notifySubscribers(), never awaited, so a slow or failed Bedrock call can't block a notification.settings:analysis-debounce-minutes (editable from admin) overrides the ANALYSIS_DEBOUNCE_MINUTES env default without a redeploy.api/_lib/analysisPrompt.ts includes a hand-curated resiliency reference table per service category, so the model cites reviewed guidance rather than inventing specifics from a thin vendor paragraph, and a hard rule keeps DR/failover advice conditional ("if a failover path exists, consider…").BEDROCK_MODEL_ID must be a cross-region inference profile ID — Claude models on Bedrock have no bare on-demand ID. Production uses us.anthropic.claude-sonnet-4-6; the code default (us.anthropic.claude-sonnet-5) isn't usable on this AWS account until Sonnet 5/5.5 access clears with AWS support (see claude.md §9, Marketplace IAM row).sts:AssumeRoleWithWebIdentity via @vercel/oidc-aws-credentials-provider — no static AWS keys. Role provisioned by scripts/setup-bedrock-oidc.sh (api/_lib/bedrock.ts).incident_analysis row per trigger, not per incident. A failed run (Bedrock error, malformed tool response, missing field) still writes a status='failed' row with the error, so a trigger is never lost silently.api/_lib/pdf/). Helvetica built-in fonts, Synepho-branded, uploaded to incident-analysis/{provider}/{incident-slug}/{row-id}-{technical|executive}.pdf with a 1-year immutable cache. A footer on every page carries the disclaimer and a "Powered by Synepho" line. The dashboard shows "Download PDF" only once a URL exists.The emit_incident_brief tool schema asks the model for fixed fields
(src/utils/structuredBrief.ts) rather than free-form markdown, which
drifted in format from run to run:
whatWeKnow, nextActions[], servicesToCheck[], resiliencyQuestions[]. Actions and question groups carry an urgency: immediate (red), high (orange), medium (amber), monitor (blue).bottomLine (stance act-now / decide-if-confirmed / awareness), whatsHappening, seriousness, customerImpact (likelihood yes / possible / unlikely), and conditional decisions[].parseStructuredBriefs() drops malformed list items, sorts lists most-urgent
first, and fails the run if a required field is missing. Claude sometimes
returns a nested object as a JSON-encoded string ("executive": "{...}"), so
any object or array field that arrives as a string is decoded before it's
checked. If the response still fails, the pipeline calls Bedrock once more
(MAX_ATTEMPTS = 2, so api/analysis/run.ts and analysis-admin.ts allow
150s). A failed row's error lists each attempt's stopReason and field
types, and token counts cover every attempt. The JSON is stored
in incident_analysis.briefs_structured; StructuredBrief.tsx (dashboard)
and BriefDocument.ts (PDF) render it with the same section order and
colors. technical_brief/executive_brief still get a plain-text rendering
for anything reading those columns, and rows from before structured briefs
render through the older markdown path (formatBriefText.tsx /
renderBriefBody()).
Layout: a panel at least 1100px wide (a container query on
.ai-insight-body, not the viewport) splits each brief into two columns;
narrower panels, phones, and the PDF use one column. The status chip is
hidden when the provider reports unknown (Azure's feed has no status).
sequenceDiagram
actor User
participant Panel as AI Insight panel
participant Edge as Vercel edge cache
participant API as /api/analysis/latest
participant DB as Neon Postgres
User->>Panel: Expand AI Insight (first time)
Panel->>Edge: GET ?provider=&incidentId=
Edge->>API: (cache miss, 5-min TTL)
API->>DB: Latest complete row id
API-->>Panel: { id, createdAt }
Panel->>Edge: GET ?id={row id}
Note over Edge: Immutable, cached 1 year once finalized —<br/>later views never reach Postgres
Edge-->>Panel: Brief JSON + PDF links
The dashboard shows Recently resolved (last 24h, straight from the
provider feeds) and a collapsed Past incidents · last 90 days section
below it. Provider feeds keep almost no history — GCP's incidents.json
holds a handful of incidents, AWS's all.rss a few dozen updates — so the
app records its own.
check-status.ts upserts one incident_history row per resolved incident (api/_lib/incidentHistory.ts), merging the last active snapshot (real start time and severity) with the provider's own resolved entry when there is one (real end time and final text). Renamed incidents are skipped. It only runs on ticks where something resolved, so Neon isn't woken every 5 minutes.GET /api/incidents/history?cursor= returns incidents resolved 24h–90d ago, 50 per page, each with its latest complete AI brief id. Only called when the section is expanded, and edge-cached for 1h.cleanup.ts deletes rows older than HISTORY_RETENTION_DAYS (90). Linked incident_analysis rows are pruned separately (see AI run history and retention).resolved rows in incident_analysis./admin is gated behind Sign in with Vercel.
id_token's email claim to ADMIN_EMAIL, bouncing anyone else with ?error=forbidden. A separate HMAC-signed session cookie (7-day expiry) is then issued, independent of Vercel's 1-hour access token.GET /api/admin/subscribers with status, provider, and q (name/email substring) filters; ?format=csv exports with the same filters. PATCH /api/admin/subscribers/:id supports resend_confirmation / force_unsubscribe; DELETE hard-deletes./api/admin/test-email sends any notification template to a real confirmed subscriber's address.settings:analysis-disabled-providers in Redis, read once per cron tick, fails open). The kill switch only stops AI briefs — outage and resolution emails still go out.Run history (RunHistoryPanel.tsx) lists the last 200 incident_analysis
rows, including failures. Any row can be re-run — not just failed ones, e.g.
to pick up a prompt change — which always inserts a new row through the
same pipeline with bypassDebounce: true. PDF links go through
/api/admin/analysis-admin?resource=pdf&kind=…&id=…, which checks the
stored URL server-side before redirecting, so database values never reach
an href.
The dashboard only ever shows an incident's newest complete run, so older
runs pile up unreachable. api/_lib/runRetention.ts labels every run in one
SQL query, shared by the Link column and the Clean up unused (N)
button so the two can't disagree:
| Label | Meaning | Cleanup |
|---|---|---|
| Active | Newest complete run of a currently active incident | Kept |
| Past · until <date> | Newest complete run of an incident in incident_history (resolved within 90 days) | Kept |
| Recent | Newest complete run from the last 24h (safety net for a resolved incident whose history row was never written) | Kept |
| Superseded · grace | Replaced less than 2h ago — edge caches may still serve its id | Kept |
| Failed · kept | A failure with no successful run since, under 30 days old | Kept |
| Superseded | Replaced by a newer complete run | Removed |
| Failed · resolved | Retried successfully, or older than 30 days | Removed |
| Unlinked | Not reachable from the dashboard | Removed |
Cleanup is manual only. It deletes rows and their Blob PDFs in one statement, so classification and deletion see the same snapshot, and it refuses to run if any provider's Redis status snapshot is missing or more than 24h old. A per-row Delete still works on a linked run but warns that the dashboard's AI Insight panel may stop working.
erDiagram
subscribers ||--o{ notification_log : "receives"
incident_history }o..o{ incident_analysis : "provider + incident_id"
subscribers {
uuid id PK
text name
text email UK
text_array providers "e.g. {aws,gcp} or {all}"
text_array pending_providers "staged change awaiting confirmation"
text status "pending_confirmation | confirmed | unsubscribed"
text confirm_token
text manage_token UK
timestamptz created_at
}
notification_log {
uuid id PK
uuid subscriber_id FK
text provider
text channel
text event_type "new_incident | incident_resolved"
boolean success
timestamptz sent_at
}
incident_analysis {
uuid id PK
text provider
text incident_id
text trigger_event "new | content_changed | resolved"
jsonb incident_snapshot
jsonb briefs_structured
text technical_brief
text executive_brief
text model
text pdf_technical_url
text pdf_executive_url
text status "complete | failed"
timestamptz created_at
}
incident_history {
text provider PK
text incident_id PK
text title
timestamptz start_time
timestamptz resolved_at
jsonb snapshot
}
The full DDL is in scripts/db/*.sql. A few notes:
pending_providers holds a staged edit awaiting re-confirmation. "all" is stored as a literal sentinel so future providers are included automatically.incident_analysis has one row per trigger, indexed on (provider, incident_id, created_at DESC) for the debounce check and latest-brief lookup.provider_status_snapshot (migrations 003/005) is unused and kept only as a historical record — snapshots moved to Redis (see below).Redis keys (Upstash):
| Key | Purpose |
|---|---|
snapshot:<provider> | Last-seen incident IDs, titles, and content hashes for change detection |
resolved:azure | Azure incidents that vanished from the feed, shown as resolved for 24h |
settings:analysis-debounce-minutes | Live AI debounce override |
settings:analysis-disabled-providers | Per-provider AI kill switch |
| sign-up limit counters | Per-email (hashed) and per-day sign-up counts |
Snapshots live in Redis rather than Postgres because a query every 5 minutes kept Neon's compute from ever autosuspending; Redis bills per request instead. See CHANGELOG.md → Status snapshots moved to Redis.
| Piece | Role | Status |
|---|---|---|
| Vercel | SPA hosting, functions, daily cron fallback, Blob | Live |
| Neon Postgres (Marketplace) | Subscribers, notification log, AI briefs, incident history | Live |
| Upstash Redis (Marketplace) | Status snapshots, settings, sign-up limits | Live |
| Resend | Confirmation, welcome, outage, and resolution email from alerts.synepho.com | Live, domain verified |
| AWS EventBridge (Rule + Connection + API Destination) | Calls /api/cron/check-status every 5 minutes — scripts/setup-eventbridge-cron.sh | Live |
| AWS Bedrock | AI briefs via OIDC federation — scripts/setup-bedrock-oidc.sh | Live |
| Vercel Blob | PDF briefs, public, 1-year immutable cache | Live |
| Sign in with Vercel | Admin auth, restricted to ADMIN_EMAIL | Live |
| Bot protection | Honeypot + per-email/daily sign-up limits (BotID removed) | Live |
| Vercel Firewall | Rate limit on /api/subscribe (5 req/60s/IP) | Live |
AWS Route 53 (synepho.com) | DNS, including Resend's MX/SPF/DKIM and a p=none DMARC record for alerts.synepho.com — scripts/setup-resend-dns.sh | Live |
| SMS (Twilio) | — | Not built (deferred, A2P 10DLC registration) |
Everything above runs on a free tier.
The dashboard itself needs none — all four status sources are public. The
backend's variables are already set on the Vercel project (Production,
Preview, Development); pull them with vercel env pull .env.local.
.env.example lists every variable with a short note.
| Variable | Used by |
|---|---|
DATABASE_URL | Neon Postgres |
KV_REST_API_URL, KV_REST_API_TOKEN | Upstash Redis |
RESEND_API_KEY, RESEND_EMAIL_DOMAIN | |
APP_BASE_URL | Links in emails |
CRON_SECRET | EventBridge / Vercel cron and /api/analysis/run auth |
VERCEL_OAUTH_CLIENT_ID, VERCEL_OAUTH_CLIENT_SECRET, SESSION_SECRET, ADMIN_EMAIL | Admin auth |
AWS_ROLE_ARN | Bedrock OIDC role (printed by scripts/setup-bedrock-oidc.sh) |
AWS_REGION | Pin to us-east-1 — Vercel can inject a value that drifts under multi-region routing |
BEDROCK_MODEL_ID | Optional; code default us.anthropic.claude-sonnet-5, production override us.anthropic.claude-sonnet-4-6 |
ANALYSIS_DEBOUNCE_MINUTES | Optional, default 30; the Redis setting overrides it |
BLOB_READ_WRITE_TOKEN | Auto-injected once a Vercel Blob store is connected |
scripts/rotate-secrets.sh audits and rotates the secrets above
(#4). It is a dry run
by default: it prints the plan and changes nothing in Vercel or AWS until
--apply is passed.
npm run secrets:audit # read-only; exits 1 if anything needs attention
scripts/rotate-secrets.sh --rotate resend # print the plan
scripts/rotate-secrets.sh --rotate resend --secure --apply
scripts/rotate-secrets.sh --secure RESEND_API_KEY --apply
| Target | Variables | How |
|---|---|---|
cron | CRON_SECRET | Automatic: new value → Vercel production → redeploy → EventBridge connection, then waits for a successful run in CloudWatch |
session | SESSION_SECRET | Automatic: new value → redeploy. Signs you out of /admin |
resend | RESEND_API_KEY | Guided |
blob | BLOB_READ_WRITE_TOKEN | Guided |
upstash | KV_*, REDIS_URL | Guided. The old token stops working immediately |
neon | DATABASE_URL, POSTGRES_*, PG* | Guided. The old password stops working immediately |
Connected services (Resend, Blob, Upstash, Neon) have no public API for their "Rotate Secrets" button, so the script opens the dashboard and waits for the click, then handles everything after it:
flowchart TD
plan["Dry run<br/>prints the plan"] -->|"--apply"| open["Open dashboard<br/>wait for Rotate Secrets click"]
open --> changed{"Variable's updatedAt<br/>changed in Vercel?"}
changed -->|yes| redeploy["Redeploy production"]
changed -->|"no: confirm first"| redeploy
redeploy --> build["Wait for new buildDate<br/>in /version.json"]
build --> pull["Back up and re-pull .env.local"]
pull --> check["Credential check<br/>rotate-secrets-check.mjs"]
check --> secure{"--secure?"}
secure -->|yes| split["Sensitive Production + Preview<br/>readable Development copy"]
secure -->|no| done["Done"]
split --> done
cron. Vercel is updated and redeployed before the
EventBridge connection, so at most one 5-minute status check gets a 401.
The Development copy of CRON_SECRET is left alone; if you re-run
setup-eventbridge-cron.sh, give it the production value.select 1, Redis PING,
Blob list, Resend GET /domains. No data is written and no email is sent.--secure clears Vercel's "Needs Attention" badge. Sensitive values
can't target Development, so the script keeps a readable Development copy for
vercel env pull. After the change, the production value can't be read
back from Vercel. Whether Vercel allows this on service-owned variables is
unverified; try one KEY first. If Vercel refuses, nothing changes.VERCEL_OAUTH_CLIENT_SECRET comes from the Sign in with
Vercel app settings, so rotate it there and set it with
vercel env update VERCEL_OAUTH_CLIENT_SECRET production --sensitive. The
audit still reports its age.Migrations live in scripts/db/*.sql and run in filename order:
vercel env pull .env.local
set -a && source .env.local && set +a
node scripts/db-migrate.mjs
Redis needs no migration — it's provisioned once through the Vercel Marketplace integration.
csp-status-hub/
├── api/ Vercel serverless functions
│ ├── status/azure.ts Azure feed proxy
│ ├── subscribe/[action].ts Sign-up, confirm, manage, unsubscribe
│ ├── auth/[action].ts Sign in with Vercel (authorize, callback, signout)
│ ├── admin/
│ │ ├── subscribers/[id].ts Subscriber list/CSV + per-subscriber actions
│ │ ├── test-email.ts Ad hoc template preview
│ │ └── analysis-admin.ts AI run history, retry, cleanup, settings, PDF redirect
│ ├── analysis/
│ │ ├── run.ts Generate a brief (CRON_SECRET-gated)
│ │ └── latest.ts Public brief pointer / immutable content
│ ├── incidents/history.ts Past incidents (24h–90d)
│ ├── cron/
│ │ ├── check-status.ts Change detection, emails, AI + history triggers
│ │ └── cleanup.ts Daily retention purge
│ └── _lib/
│ ├── analysisPipeline.ts Bedrock → Postgres → PDF pipeline (shared by run + admin retry)
│ ├── analysisPrompt.ts System prompt, user message, tool schema
│ ├── analysisSettings.ts Live Redis settings (debounce, kill switch)
│ ├── bedrock.ts Bedrock client via Vercel OIDC
│ ├── pdf/ BriefDocument, render + Blob upload, slug
│ ├── runRetention.ts Per-run labels + cleanup query
│ ├── incidentHistory.ts History writes + 90-day retention constant
│ ├── incidentRenames.ts Renamed-incident matching
│ ├── resolvedIncidents.ts Azure resolved-incident cache
│ ├── signupLimits.ts Honeypot + per-email/daily limits
│ ├── azureFetcher.ts Azure fetch/parse (proxy + cron)
│ ├── email.ts Resend templates
│ ├── db.ts · redis.ts Neon and Upstash clients
│ ├── auth.ts · session.ts · cookies.ts Admin session gate
│ └── types.ts Re-exports shared types
│
├── src/
│ ├── main.tsx Entry point + path routing (/, /manage, /admin)
│ ├── App.tsx Root layout, title, analytics, version banner
│ ├── components/
│ │ ├── StatusHeader.tsx Title, live dot, refresh, Get alerts, Settings
│ │ ├── ProviderGrid.tsx 4-column grid with per-provider skeletons
│ │ ├── ProviderPanel.tsx Expandable provider card
│ │ ├── ProviderFilter.tsx All / AWS / Azure / OCI / GCP filter bar
│ │ ├── RegionTable.tsx Region → top-10 service rows
│ │ ├── FlatServiceList.tsx Top-10 list when no incident is active
│ │ ├── IncidentList.tsx Active + recently resolved incidents
│ │ ├── IncidentCard.tsx Incident row with link + AI Insight
│ │ ├── IncidentBriefPanel.tsx AI Insight panel (Technical/Executive, PDF)
│ │ ├── StructuredBrief.tsx Structured brief renderer
│ │ ├── PastIncidents.tsx 90-day history section
│ │ ├── SubscribeModal.tsx Sign-up form
│ │ ├── ManagePage.tsx /manage
│ │ ├── AdminPage.tsx /admin
│ │ ├── admin/ RunHistoryPanel, AnalysisSettingsPanel
│ │ └── … StatusBadge, ServiceRow, ErrorState, SettingsMenu, AboutModal, …
│ ├── fetchers/ aws, azure, oci, gcp, analysis, history
│ ├── hooks/ useStatusPolling, useIncidentBrief, useIncidentDeepLink, useTheme, useVersionCheck
│ ├── seo/providerPages.ts Per-provider page titles and crawler text (build-time only)
│ ├── types/status.ts Unified schema
│ └── utils/ Per-provider service lists, providerPages, structuredBrief, formatters, withTimeout, analytics, …
│
├── scripts/
│ ├── db/*.sql Migrations (001–008)
│ ├── db-migrate.mjs Migration runner
│ ├── setup-eventbridge-cron.sh EventBridge cron trigger
│ ├── setup-bedrock-oidc.sh Bedrock OIDC provider + role
│ ├── setup-resend-dns.sh Resend DNS records in Route 53
│ ├── rotate-secrets.sh Secret audit and rotation (dry run by default)
│ ├── rotate-secrets-check.mjs Side-effect-free credential checks for it
│ ├── deploy.sh Deploy wrapper
│ ├── verify-gcp-services.mjs Re-validate GCP productIds
│ └── verify-ga.mjs Confirm GA4 hits leave the live site
│
├── docs/
│ ├── ENHANCEMENTS.md Backlog
│ ├── PROMPT-REFINEMENT-GUIDE.md AI prompt walkthrough
│ ├── CUSTOM-DOMAIN-PLAN.md Move to cloudstatus.synepho.com
│ └── AWS-MIGRATION-ASSESSMENT.md Assessment of moving off Vercel
│
├── claude.md Original architecture handoff + data source research
├── CHANGELOG.md Shipped work and design history
├── vercel.json Redirects, rewrites, function config, crons
└── vite.config.ts Build constants, dist/version.json, sitemap.xml, provider pages
Prerequisites: Node.js 20+, npm
npm install
npm run dev # Vite frontend only, http://localhost:5173
npm run build # type-check + build
npm run lint
npm run verify:gcp # check GCP service IDs against the live product catalog
npm run verify:ga # confirm GA4 page_view hits leave the live site (needs: npm i --no-save playwright && npx playwright install chromium)
npm run dev runs only the frontend — Azure and everything under /api/*
will fail. For the full app (Azure data, sign-up, manage, admin, cron), use
the Vercel CLI:
vercel env pull .env.local # first time, or after env vars change
vercel dev --listen 3002 # any free port
Under vercel dev the app uses the production Upstash Redis, so local test
sign-ups count against an address's 3-per-day limit and the 30-per-day
total. Calling check-status locally can send real emails — see
Known constraints.
The site runs on Vercel at cloudstatus.synepho.com. The old csp-status-hub.vercel.app URL permanently redirects there (see docs/CUSTOM-DOMAIN-PLAN.md).
Pushing to main deploys to production automatically through Vercel's Git
integration. Check the live build with:
curl https://cloudstatus.synepho.com/version.json
Manual deploys are also available (lint → build → deploy → open browser):
npm run deploy # production
npm run deploy:preview # preview URL
First-time CLI setup: vercel login, then vercel link.
Cron: /api/cron/check-status (every 5 min) is triggered by AWS
EventBridge, provisioned separately with scripts/setup-eventbridge-cron.sh.
It isn't part of a deploy and only needs re-running if the cron setup
changes — including after a domain change. /api/cron/cleanup (daily) uses
Vercel's native cron from vercel.json.
| Feature | Status | Notes |
|---|---|---|
| Web Analytics | ✅ Live | Vercel Web Analytics |
| Speed Insights | ✅ Live | Core Web Vitals |
| Function logs | ✅ Live | vercel logs <url> --follow or the Logs tab |
| Notification log | ✅ Live (data only) | notification_log records every send attempt; no admin UI reads it yet |
| Sign-up funnel | ✅ Live | GA4 events (production host only): subscribe_open {source, provider} when the form opens, and subscribe_success {providers, source} on submit. source is header, all_clear, incident_card or shared_link |
| Provider filter | ✅ Live | GA4 provider_filter {provider} when the filter bar is clicked (all, aws, azure, gcp, oci). Provider pages also show up as their own paths (/aws etc.) in page views |
| GA4 health check | ✅ Live | Daily GitHub Action (verify-ga.yml, 13:00 UTC) loads the live site in a clean headless browser and fails unless a GA4 page_view reaches /g/collect. GitHub emails on failure. Catches silent breakage that a loaded gtag.js hides |
APP_BASE_URL. It lives in the AWS API Destination and in scripts/setup-eventbridge-cron.sh's TARGET_ENDPOINT, outside the app's deploy. Any domain change must re-run that script; it self-heals drift on the endpoint and rule description, but only when run.[id].ts / [action].ts file needs an explicit vercel.json rewrite — worth knowing before adding another.check-status is not a safe healthcheck. It detects changes and sends real emails in the same request, so calling it manually can send live notifications.<title> when it resolves. Some incidents state the resolution only in <description>, so awsFetcher.ts's isResolved() checks both./api/analysis/latest?id=<row id> to see a fresh run immediately.More provider-specific caveats are in claude.md §9.
Shipped work is listed in CHANGELOG.md.
Pending (details in docs/ENHANCEMENTS.md):
fast-xml-parseraria-expanded, aria-controls) on expandable panels/incidents/aws/…) for outage searches, which would also mean keeping incident history past 90 days--apply run of scripts/rotate-secrets.sh (Resend or Blob first, Neon last), then an optional monthly audit GitHub Action (#4)Maintained by John Xanthopoulos
GET /api/subscribe/unsubscribe