# Architecture
gh-attest is a single [Cloudflare Worker](../src/index.ts) with three entry
points — an HTTP router, an hourly cron, and a queue consumer — backed by D1,
R2, and a Queue, all provisioned in Cloudflare's `eu` jurisdiction. It ingests
GitHub security posture as immutable timestamped snapshots and renders them into
auditor-ready evidence packages mapped to SOC 2 / ISO 27001 controls.
This document is the shape of the system. For *why* a given GitHub signal counts
as evidence for a given control, see [framework-mapping.md](framework-mapping.md).
## System context
```mermaid
flowchart LR
subgraph ext[External]
GH["GitHub App
webhooks · OAuth · REST API"]
USER["User / Auditor
browser"]
end
subgraph cf["Cloudflare Worker — src/index.ts"]
FETCH["fetch()
HTTP router"]
CRON["scheduled()
hourly cron"]
QUEUE["queue()
export consumer"]
end
subgraph store["Storage — EU jurisdiction"]
D1[("D1
gh-attest-db-eu")]
R2[("R2
gh-attest-exports-eu")]
Q[["Queue
generate-export"]]
end
GH -- "POST /webhooks/*" --> FETCH
USER -- "login · dashboard · exports" --> FETCH
CRON -- "poll (installation token)" --> GH
FETCH -- "snapshots · export rows" --> D1
FETCH -- "enqueue job" --> Q
FETCH -- "stream download" --> R2
CRON -- "snapshots · retention" --> D1
CRON -- "retention delete" --> R2
Q --> QUEUE
QUEUE -- "read evidence" --> D1
QUEUE -- "write file" --> R2
```
## Entry points
| Handler | Trigger | Responsibility | Code |
| --- | --- | --- | --- |
| `fetch` | HTTP request | Webhooks, OAuth/session dashboard, export requests + downloads, admin ops | [index.ts](../src/index.ts) |
| `scheduled` | Cron `0 * * * *` (hourly) | Poll GitHub for state webhooks never announce; enforce retention | [index.ts](../src/index.ts) |
| `queue` | `generate-export` message | Render CSV/PDF off the request path into R2 | [index.ts](../src/index.ts) |
### Routes and their auth
| Route | Auth | Purpose |
| --- | --- | --- |
| `POST /webhooks/github`, `/webhooks/marketplace` | HMAC (`GITHUB_WEBHOOK_SECRET`) | Ingest App / marketplace events |
| `GET /login`, `/callback`, `/logout` | OAuth state cookie | Dashboard sign-in |
| `GET /`, `/access-review` | Session cookie | Posture dashboard, membership diff |
| `POST /exports`, `/resync`, `/switch`, `/exclusions` | Session cookie | Dashboard actions (installation-scoped) |
| `GET /exports/:id[/download]` | Session cookie | Export status / file (scoped to installation) |
| `POST /admin/{poll,export,cleanup,purge}`, `GET /admin/export/:id` | Bearer (`ADMIN_TOKEN`) | Operations |
## Ingestion — two paths into one table
Both paths write to the append-only `snapshots` table; nothing is ever updated
in place, which is what makes the table a point-in-time audit trail.
### Webhooks (change events)
```mermaid
sequenceDiagram
autonumber
participant GH as GitHub
participant W as Worker (fetch)
participant D1 as D1
participant R2 as R2
GH->>W: POST /webhooks/github (event + X-Hub-Signature-256)
W->>W: verifySignature(body, GITHUB_WEBHOOK_SECRET)
alt invalid signature
W-->>GH: 401
else valid
W->>W: extractFact(event) → {resource, status}
W->>D1: upsert installations row
alt installation.deleted
W->>D1: DELETE all rows for installation
W->>R2: delete export objects
W-->>GH: 200 (purged)
else suspend / unsuspend
W->>D1: toggle suspended_at
W-->>GH: 200
else normal event
W->>D1: INSERT snapshot (append-only)
W-->>GH: 200
end
end
```
### Hourly poll + retention (baseline / drift)
Webhooks only fire on change, so protection that existed *before* install, and
current membership, would never appear. The cron closes that gap and enforces
the retention windows in the same invocation.
```mermaid
sequenceDiagram
autonumber
participant Cron as scheduled (hourly)
participant W as Worker
participant GH as GitHub REST
participant D1 as D1
participant R2 as R2
Cron->>W: fire
par Poll every active installation
W->>GH: app JWT → installation token
W->>GH: repos (minus exclusions) · branch protection · rulesets · org/team members
GH-->>W: current state
W->>D1: INSERT snapshots (one captured_at per batch)
and Retention cleanup
W->>D1: SELECT expired export r2_keys
W->>R2: delete expired objects
W->>D1: DELETE exports > 90d, snapshots > 396d
end
```
## Export pipeline
Rendering runs off the request path via the Queue because PDF / large CSV can
exceed request CPU limits. The `exports` row is the job's state machine
(`queued → processing → done | error`).
```mermaid
sequenceDiagram
autonumber
participant U as User (session)
participant W as Worker (fetch)
participant D1 as D1
participant Q as Queue
participant C as Worker (queue)
participant R2 as R2
U->>W: POST /exports (framework, format)
W->>D1: INSERT exports (status=queued)
W->>Q: send job
W-->>U: 303 redirect to dashboard
Q->>C: deliver job
C->>D1: status=processing
C->>D1: buildEvidenceRows — snapshots ⋈ control_mappings
C->>C: renderCsv / renderPdf
C->>R2: put file at exports/{installation}/{jobId}.{fmt}
C->>D1: status=done, r2_key
Note over C,D1: render failure → status=error (deterministic, not retried)
U->>W: GET /exports/:id/download
W->>D1: lookup scoped to session installation
W->>R2: get object
R2-->>W: file body
W-->>U: stream (Content-Disposition: attachment)
```
## Data model
`control_mappings` has **no foreign key** to `snapshots`. Mapping is a join on
`(resource, status)` performed at query/export time — so a mapping can be
corrected without re-ingesting webhook history. A mapping row with `status =
NULL` matches any status for that resource.
```mermaid
erDiagram
installations ||--o{ snapshots : has
installations ||--o{ exports : has
installations ||--o{ repo_exclusions : has
snapshots }o..o{ control_mappings : "query-time join on (resource, status)"
installations {
integer installation_id PK
text org_login
text installed_at
text suspended_at "null unless suspended"
}
snapshots {
integer id PK
integer installation_id FK
text repo "null for org-level facts"
text subject "member/team for access facts"
text resource "e.g. branch_protection"
text status "e.g. enabled | open | added"
text raw_payload "original JSON, audit trail"
text captured_at
}
control_mappings {
integer id PK
text resource
text status "null matches any"
text framework "soc2 | iso27001"
text control_id "e.g. CC8.1 | A.8.32"
text posture "positive | negative | informational"
text rationale
}
repo_exclusions {
integer installation_id PK,FK
text repo PK "full name, out of scope"
text excluded_at
}
exports {
text id PK "uuid"
integer installation_id FK
text framework
text format "csv | pdf"
text status "queued|processing|done|error"
text r2_key "null until rendered"
text error
text created_at
text completed_at
}
```
### Evidence query
`buildEvidenceRows` ([exporter.ts](../src/exporter.ts)) reduces the append-only
table to current posture, then attaches controls:
```mermaid
flowchart TB
S[("snapshots
append-only")] --> L["latest row per
(repo, subject, resource)"]
L --> J{{"JOIN control_mappings
on resource + status"}}
CM[("control_mappings")] --> J
J --> F["filter by framework
(soc2 | iso27001 | all)"]
F --> C["collapse branch_protection
+ repository_ruleset to one row"]
C --> E["evidence rows
control · posture · rationale"]
E --> CSV["renderCsv"]
E --> PDF["renderPdf"]
```
Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply
produce no rows — no evidence in either direction.
Repos listed in `repo_exclusions` are filtered out of the result and skipped by
the poll, so an excluded repo costs no subrequests and reports no gaps; its
snapshots stay in the table, so removing the exclusion restores its history.
Classic branch protection and repository rulesets both attest the same control,
so the two are collapsed to one row per (framework, control, repo) — enabled
wins over disabled — rather than letting an unused mechanism report a gap the
other one covers.
## Boundaries & isolation
- **Multi-tenant scoping.** Every session route is scoped to the session's
`installationId`; the allowed set lives in the signed session, so a tampered
id can't widen access. Export downloads are looked up with an installation
filter, so one org can't read another's file by guessing its UUID.
- **Three auth schemes.** HMAC for webhooks, signed session cookies
(`SESSION_SECRET`) for the dashboard, timing-safe bearer (`ADMIN_TOKEN`) for
`/admin/*`.
- **Data residency.** D1, R2, and the Queue are all in the `eu` jurisdiction;
see [wrangler.jsonc](../wrangler.jsonc). No source code or access tokens are
stored — only posture facts and their raw event JSON.
- **Deletion.** Uninstall (`installation.deleted`) purges all D1 rows and R2
objects for the installation; `/admin/purge` does the same on request.
## Where things live
| Concern | File |
| --- | --- |
| Router, handlers, retention, purge | [src/index.ts](../src/index.ts) |
| Webhook verification + fact extraction | [src/webhook.ts](../src/webhook.ts) |
| GitHub polling (protection, rulesets, access) | [src/poller.ts](../src/poller.ts) |
| App JWT + installation tokens | [src/github-app.ts](../src/github-app.ts) |
| OAuth + session sign/verify | [src/auth.ts](../src/auth.ts) |
| Evidence query + CSV/PDF rendering | [src/exporter.ts](../src/exporter.ts) |
| Access-review diff | [src/access-review.ts](../src/access-review.ts) |
| Dashboard HTML | [src/dashboard.ts](../src/dashboard.ts) |
| Schema + control mappings | [migrations/](../migrations) |
```