Personal site of Marco Bellingeri, Cloud Platform & AI Security Engineer. Static Astro, bilingual EN/IT, with a monthly magazine fed by a RAG pipeline.
The site audits itself. The Security section does not declare the security headers, it
reads them back out of the HTTP response the browser has just received. That is why the
Content Security Policy in this repository is built from script hashes instead of
unsafe-inline, and why it is verified in CI against dist/ rather than the source.
| Directory | What it holds |
|---|---|
astro-project/ |
The site. Static Astro, EN/IT i18n, components, CSP, tests. Start here. |
engine/ |
The Node pipeline behind the monthly issue: sourcing, verification, generation, embed, export, judge (LLM-as-a-judge on the content PR), competitor radar. Plus distribution: devto (draft at merge, publish on the frontmatter date) and edicola (automatic cards). No external dependencies. |
supabase/ |
Migrations, seed and RLS policies for the RAG database (Postgres + pgvector). Rebuildable from scratch. |
docs/adr/ |
The architectural decisions and the reasoning. docs/FONTI.md holds the licence registry for the Radar sources, every one with a verified licence; the guard in CI runs on the machine-readable registry, src/data/radar-fonti.js. |
mock-html-singolo/ |
The HTML prototype everything grew out of. Historical reference, not to be touched. |
cd astro-project
npm install
npm run dev # development
npm run check # type-check the .astro files (strict tsconfig)
npm run lint # ESLint, the only eyes on the .astro files
npm run build # static build into dist/
npm run test:csp # tests run against dist/, not the sourcecheck and lint also run in CI and on the path to deploy, not just in Site CI. That
workflow is separate and the deploy does not wait for it, so a gate living only there would
stop nothing.
To serve the site with the real headers, the ones from public/_headers that
astro preview does not apply:
npx wrangler devThe pipeline needs its secrets from Doppler:
cd engine
doppler run -- npm run ingest
npm test # unit + integration, no networkThe CSP allows no unsafe-inline on script-src: bundled scripts are authorised by hash,
computed by Astro at build time, and the hash of the anti-FOUC script (which is is:inline,
so Astro leaves it alone) is declared by hand in astro.config.mjs. Change it and
npm run test:csp fails, telling you exactly which hash to use.
frame-ancestors is the only CSP directive left in public/_headers, because a <meta>
would ignore it by spec. Everything else lives in the meta tag generated at build time, the
only place where the hashes can be computed.
Other nets:
- SonarQube Cloud quality gate on the path to deploy: in
deploy.ymlthe analysis job runs before publication (sonar.qualitygate.wait=true), so a red gate means no production. On PRs the analysis arrives as a check, while the change can still be discussed. Coverage is computed by the Node test runner, with no extra dependency. astro check+ ESLint, because Sonar on its own left more than half the site uncovered: it has no Astro parser, and the 27.astrofiles (roughly 4600 lines, more than everything Sonar does analyse) passed with no static checking at all. That is exactly where the browser-side logic lives: contact form, command palette, terminal. Thetsconfigwas alreadystrict, but nobody ran it, which is decorative severity. No Prettier: it formats, it does not find bugs, and style arguments take two people. The ESLint config disables no rules at all. The only exception sits on the line itself with the reason next to it (the Worker's anti-header-injection regex, where control characters are the target rather than the mistake).- One list of security headers for everything the Worker generates, in
worker/headers.js.public/_headerscovers static assets and the API responses never passed through it, so the list had been copied into four places and had started to drift. Two mirrors hold it: one test writes the five values out in full and checks the Worker's responses carry them, another readspublic/_headersand fails if the two ever disagree. - A rate limit on the three routes that generate work (
/api/contact,/api/ask,/api/agentic-status), declared inwrangler.jsoncas a runtime binding rather than a WAF rule, so it is reviewed in a pull request like everything else./api/radarhas none: it is answered from the edge cache for half an hour at a time, so a flood costs one round of feeds. The counter is per Cloudflare location and eventually consistent by design: a ceiling against a flood from one source, not an exact quota. Verifying one needs every request on a single connection: spread them across parallel connections and each gets its own counter, so a working limit looks absent. - gitleaks across the whole history on every push to
main, and in a local pre-commit hook. - Push protection from secret scanning: GitHub refuses a push containing a secret instead of discovering it afterwards.
- RLS on every table, verified in CI by rebuilding the database from scratch and making
the publish gate bite. An issue cannot reach
publishedwithout proof of its sources. - Secrets on Doppler, never in the repository.
.envis ignored.
The pipeline stops at a tailored Level 3 (blocking gates on both the application and the supply chain), and every stop has a reason:
- No human approval gate on production:
mainis production, by choice. The cost of a mistake is low, the Cloudflare rollback is immediate, and the automatic gates (Sonar, CSP on dist, gitleaks, ruleset) all sit on the path to deploy. - No OIDC towards Cloudflare: not out of laziness. Cloudflare does not expose OIDC federation for API tokens. The compensating control is a minimally scoped token. This is a declared ceiling, not debt.
- No canary or progressive delivery: at this traffic level a 5% canary cannot reach statistical significance before the rollout ends. Theatre, not a gate.
- Active supply chain: actions pinned to SHA (kept current by Dependabot), a CycloneDX
SBOM on every deploy, a signed provenance attestation (keyless, OIDC) that is verified,
SAST on the workflows themselves (zizmor, blocking on High), and minimal
GITHUB_TOKENpermissions per job. - Gate policy: every gate declares what it blocks in the comment beside it. A gate with no
written policy is a future
continue-on-error.
Vulnerabilities: do not open a public issue, see SECURITY.md.
Enable the anti-secret hook once per clone:
git config core.hooksPath .githooks
brew install gitleaksSentry, free plan, DE region. The principle: only turn on what, when it rings, says something you would not have known otherwise.
- Errors on the client and the Worker. On the Worker,
withSentrycatches unhandled exceptions; the form's handled failures (Turnstile with no secret, Resend answering badly) go through the__SEGNALA_SENTRY__hook, becausewithSentryalone would never see them. They arereturns, notthrows. On the client the SDK loads lazily (first interaction or first idle): its whole cost sat on the critical path and it was the last reason mobile TBT was not zero. Errors raised before it loads land in a buffer and leave as soon as the SDK arrives (sentry.client.config.js). - Tracing on
/api/contactonly.run_worker_firstsends the APIs and every HTML page through the Worker, so a global sample rate would trace a page being handed back from the edge cache, spending quota to learn that the CDN is fast. The one route whose latency can genuinely degrade is the form, which talks to two third parties. Assets and fonts never reach the Worker at all, which is both the point and the reason the bill stays flat. - Cron monitor on the Supabase keepalive. The workflow already opens an issue when the ping fails; nobody notices when the job never runs at all, and that is the scenario that pauses the database (GitHub disables schedules after 60 days of inactivity on the repo). A cron monitor makes that absence observable, and it sits outside GitHub so it does not share the failure domain it watches. The check-in cannot make the ping fail: a watchman that kills what it watches is worse than no watchman.
- An active watchdog for the other three schedules, because there is only one seat.
Sentry includes exactly one cron monitor per plan.
magazine-ingest,visibilityandllm-council-e2eall register their monitors, all receive their check-ins, and all sitdisabledbehind the quota — verified through the API on 2026-08-13, which is the only way that fact ever surfaces. So the pattern is inverted for them:scripts/sentinella-cron.mjsruns weekly, asks the GitHub API when each schedule last fired (onlyscheduleruns count — a manual dispatch proves the workflow works, not that the cron does), and sends aCronMutoerror event for any that has gone quiet, plus one deduplicated issue. Error quota is ample and nearly unused; cron seats are one. An error event alarms on something that happened and a cron monitor on the absence of a signal — they are not the same instrument, and covering an absence with an event still needs someone to notice it first. That someone is the watchdog. Worth writing down, or in six months it reads as a downgrade. Verified end to end rather than assumed: a forced quiet cron produced the event, and Sentry filed it at high priority, which is what the existing notification automation fires on. Mutual cover closes the circle (2026-08-14). The watchdog runs daily and now also watchessupabase-keepalive; the keepalive — the one job with an active Sentry monitor — runs the same script in turn, so it is the eye on the watchdog. Every link has something above it: watchdog → the four crons, keepalive → watchdog, Sentry monitor → keepalive. No seat changed hands, and the keepalive keeps its direct alarm, which matters because a paused database costs more than everything else. The step in the keepalive iscontinue-on-error: a guard that fails what it hosts is worse than no guard. - No session replay, by choice. It would record the DOM of a form where people type their name, email and brief, on a site that says it does not track, in exchange for 50 sessions a month, which is a sample that answers no question at all.
The Worker to Sentry path was verified end to end, not inferred: running the Worker without its secrets produces the two expected events in Sentry. That is worth saying because for weeks the path existed without anyone ever having seen it work.
In the engine, tracing goes to Langfuse and errors go to Sentry through a zero-dep fail-open
library. Every script has a top-level catch that reports the crash (stack, script, engine
environment) without changing its external semantics (see engine/README.md).
Two measurements, both of them uncomfortable enough to be worth having.
Discoverability, weekly: the monitor asks Google Search Console how the site ranks, and asks Perplexity and ChatGPT whether they cite it at all. ChatGPT is reached through the OpenAI API with web search, which is a proxy for chatgpt.com rather than the thing itself, and the report says so on every run. The history lives in the database, so the trend survives the week. Details in engine/README.md.
Who is actually reading, on every page request: Cloudflare's free plan reports pageviews
without separating humans from bots, and this site lets every AI crawler in on purpose, so a
raw pageview number is unusable. The Worker classifies the User-Agent by family and writes
one data point to Analytics Engine. It keeps the family, the path and the country, and never
an IP, a cookie, a session or a fingerprint. For a person it stores the single word human
and drops the User-Agent entirely. Details, including the declared quota ceiling and the SQL
to read the numbers, in astro-project/README.md.
Branches named <type>/<slug>, Conventional Commits with the subject in Italian, main
protected by a ruleset: no direct pushes, no force-pushes, PRs with green CI.
Everything is in CONTRIBUTING.md.
- ADR 0001: hosting, i18n, language detection
- ADR 0002: the monthly issue engine, human-in-the-loop
- ADR 0003: the "show-off" components
- ADR 0004: Valyu sourcing, two-channel architecture
- ADR 0005: Radar and the Atlas graph
A light scheme: a site has no API consumers, so strict semver buys nothing.
v0.x: build phasev1.0.0: go-live, first public deploy on Cloudflare- minor for a closed block, patch for a fix
Tags go on milestones, not on every commit. The Releases act as the changelog; task tracking lives in Notion, not in GitHub Issues.
- Foundation (
v0.1.0): bilingual static Astro, i18n and sitemap, components, secrets on Doppler, GDPR posture - Backend and RAG: two channels on Supabase pgvector (ADR 0004): Valyu sourcing, three-tier verification, human-in-the-loop draft, voyage-3.5 embeddings
- Engine in the repo:
engine/(ingest, generate, embed, export, competitor radar), a database rebuildable from migrations, Langfuse tracing - Site unblocked (
v0.2.0): CSP solved with hashes, Cloudflare hosting configured, frontend CI, repository made public - Go-live (
v1.0.0, 2026-07-10): marcobellingeri.dev on Cloudflare, automatic deploy frommain, www and email anti-spoofing configured - First issue (
2026-07-12): DB-backed magazine, issue #1 published ("AI insurance governance", NAIC Model Bulletin). The section does not render until a real issue exists: a magazine with a placeholder inside is worth less than no magazine - Canonical-first distribution: the site is the canonical home; dev.to is the primary mirror (native RSS import,
canonical_urlpointing back here). Long-form pieces are hosted on the site (thewritingcollection) with a Newsstand of external bylines - C1 terminal (
2026-07-21): a real RAG interface (ask), with the/api/askendpoint behind Turnstile, per-IP rate limiting, a body cap, an anon key gated topublished, plus citations and the AI Act article 50 disclosure in the payload - Editorial automation (
2026-07-21): the editorial cycle runs itself and the human gates stay. A dev.to draft is created in CI when an article is merged (devto-draft.yml); Newsstand cards come from a cron that queries dev.to and carries the PR to production once the gates are green (edicola-card.yml); the magazine runs on autopilot (monthly rotating ingest plus a daily advance that runs the stage unlocked by the last human action in Studio, with Marco merging the content PR). Engine errors go to Sentry (lib/sentry.mjs, zero-dep, fail-open) - Scheduled release (
2026-07-22): Newsstand pieces are written ahead of time and merged together, then go out on dev.to by themselves on the frontmatterdate(devto-publish-due.yml, with a 24-hour notice by issue). Silence publishes, and the one human approval left is the merge - Judge (
2026-07-22): LLM-as-a-judge on the magazine's content PR, with five anchored criteria, a written and tested gate policy (engine/lib/judge.mjs), a report in a comment, and Langfuse tracing. The merge stays human - Radar (
2026-07-22): /radar, the bulletins from security authorities (CISA, NCSC UK, CERT-FR plus an EU rules layer) on an interactive globe. Only sources whose commercially compatible licence has been verified in writing (docs/FONTI.md);/api/radarin the Worker with edge cache, per-source fail-open, and links accepted only on the source's own domains - Atlas graph (
2026-07-22): /atlas, the real graph of the personal knowledge base, 138 nodes, precomputed layout, 20KB. Technical layers only: the 373 links towards the private layers are counted and never named, behind three privacy guards (generator, allowlist test, anti-string test)
flowchart LR
subgraph EDGE["Cloudflare Worker"]
W["/ → language"]
C["/api/contact"]
A["/api/ask (RAG)"]
R["/api/radar (30' cache)"]
S["/api/agentic-status"]
CNT["HTML pages → count who asks<br/>human or named crawler"]
end
subgraph SITE["Static Astro (main = production)"]
HOME["home + sections"]
RAD["/radar: globe"]
ATL["/atlas: wiki graph"]
WRI["writing / magazine"]
end
subgraph ENGINE["engine/ (GitHub Actions cron)"]
ING["ingest (Valyu)"] --> GEN["generate (Claude)"]
GEN --> EMB["embed (Voyage)"] --> EXP["export → content PR"]
EXP --> JUD["judge → report in PR"]
DUE["devto --due (publish on date)"]
CARD["edicola (cards from the stack)"]
VIS["visibility (weekly)<br/>GSC · Perplexity · ChatGPT"]
end
DB[("Supabase pgvector<br/>publish gate in the DB")]
CERT["official CERT feeds<br/>CISA · NCSC UK · CERT-FR"]
DEVTO["dev.to (canonical → site)"]
AE[("Analytics Engine<br/>crawler_passaggi, 3 months")]
R -->|"fail-open fetch"| CERT
A --> DB
ING --> DB
EMB --> DB
EXP --> WRI
DUE --> DEVTO
CARD --> DEVTO
RAD --> R
CNT --> AE
VIS --> DB
HOME -.->|"human gates: verify · approve · merge"| ENGINE
The human gates are not in the diagram out of modesty. They are the diagram: nothing
reaches published without an action from Marco, and the block lives in the database, not in
a policy.
The code is MIT: take it, learn from it, reuse it.
The content is not. Text, design, typography, photographs and the magazine issues remain © 2026 Marco Bellingeri, all rights reserved. The code is an example of how it is built; the site belongs to one person.