# 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` | 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 · 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
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
}
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 --> 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.
## 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) |
```