This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Sigil is a web app for signing Contributor License Agreements against open
source repos. A maintainer drops a CLA.md in their repo root; contributors
visit withsigil.eu/cla/<forge>/<owner>/<repo> and sign by opening a PR that
commits a signature file to .signatures/cla/<handle>.md. Re-signing overwrites
the file, revoking deletes it.
The repo itself is the source of truth. No database, no central signature store - every signature, revocation, and re-sign is auditable via Git history on the target repo. The hosted app is a thin frontend over the forge API: OAuth, open PR, poll for merge.
Deno-first, Fresh 2.x. Stack as pinned in deno.json:
- Fresh (
@fresh/core) - file-system routing, server-rendered HTML, islands for interactivity - Vite +
@fresh/plugin-vite- build tool (Fresh 2.x runs on Vite, executed under Deno via npm: specifiers) - Preact +
@preact/signals- the component/state model for islands - Tailwind 4 via
@tailwindcss/vite - Zod - validate every query param and request body with
safeParse(add as dep when we write the first route that needs it) @deno/gfm- runtime markdown → HTML for rendering fetchedCLA.mdbodies (add when we build the signing page)
Forge calls are plain fetch. Auth is a KV-backed session cookie (see Auth flow
below) - access tokens live in Deno KV, the browser only ever sees an opaque
session id in an HttpOnly cookie.
Client data layer: @tanstack/preact-query (SSR-safe, works in islands via
npm: specifier). Install it the first time we actually issue a proxy call -
not scaffolded yet.
src/
routes/
index.tsx # landing
cla/[forge]/[owner]/[repo].tsx # signing page (the product)
auth/[forge]/login.ts # OAuth start - sets state cookie, redirects to forge
auth/[forge]/callback.ts # OAuth finish - exchanges code, creates KV session
auth/[forge]/logout.ts # POST - clears session
api/[forge]/[...path].ts # forge API proxy (session-optional)
_app.tsx # outer <html> wrapper
islands/SignBox.tsx # sign-card state machine + dialogs + dev bar
lib/
forge.ts # forge registry (OAuth endpoints, API base, scopes)
sessions.ts # Deno KV session store
cookies.ts # parse / build / clear cookies
assets/styles.css # Tailwind 4 @theme + custom brutalism CSS
main.ts # Fresh App entry
client.ts # client entry (loads styles)
utils.ts # `define` + `State` type
static/ # served as-is (favicon, logo)
vite.config.ts # Fresh + Tailwind Vite plugins (points plugin at ./src)
Import alias @/ resolves to ./src/ (set in deno.json). Root stays reserved
for config/build artifacts.
Design everything as if GitHub and Codeberg are both supported, but only
implement GitHub to start. The <forge> segment drives dispatch. Forge-specific
code (OAuth endpoints, API base URL, PR creation call) goes behind an interface
so adding Codeberg later is a new module, not a rewrite. Cookie name is
forge-scoped: session_github, session_codeberg.
GitHub OAuth App (web flow), scopes public_repo read:user. After callback:
- Exchange code → access token (server-side;
client_secretnever touches the browser) - Generate a random opaque
sessionId(32 bytes, base64url) - Write
{ token, login, forge }to Deno KV at["sessions", sessionId]withexpireIn: 30 * 60 * 1000- KV evicts it automatically at 30 min - Set
session_<forge>cookie carryingsessionId:HttpOnly,Secure,SameSite=Lax
The token never reaches the browser. XSS can't exfiltrate what isn't there. Log out = delete the KV row. Rotate = write a new row, overwrite cookie.
Deno KV works identically in dev and prod. Deno.openKv() opens a local
SQLite file in dev, hosted globally-replicated KV on Deno Deploy. Enabled via
"unstable": ["kv"] in deno.json (Deno 2 still gates KV behind the flag).
routes/api/[forge]/[...path].ts forwards any request to the forge's REST API.
Session-optional: if a valid session cookie is present, the proxy attaches
Authorization: token <access_token> from KV; otherwise the request goes
through unauthenticated, which is fine for public-repo reads. GitHub's rate
limits and token scopes are the real gates - we don't pre-validate paths. The
proxy strips hop-by-hop headers + the client's Cookie/Authorization before
forwarding.
No auth state on the client. The frontend makes same-origin fetches to
/api/github/... and treats 401 as "session expired, re-auth."
OAuth is configured via env vars (GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET).
See .env.example.
Frontmatter + markdown body. Body is the CLA text shown to contributors.
---
name: Realm # project/agreement display name; used in the signing page heading
version: 1.0 # X.Y - quoted or unquoted is fine; integers are normalized to X.0
---name drives the rendered heading on the signing page (e.g. "Realm Contributor
License Agreement"), and is embedded into each signature's attestation line.
Required.
Body is the full CLA text the contributor agreed to (copied from CLA.md at
signing time - embedded so a later edit to CLA.md can't rewrite history).
Frontmatter:
agreement_version: "<version from CLA.md at signing time>"
attestation: "I, @<handle>, agree to the following <name> Contributor License Agreement, version <version>."
client: sigil@<version>The filename stem is the canonical identity (lowercased GitHub handle) and must
match the commit author - enforce this on PR creation, not in frontmatter.
Signing time is the commit timestamp. The embedded body is the canonical record
of what was signed; later edits to CLA.md don't rewrite history, and Git
history on the signature file is the tamper-evidence.
Validation of signature PRs is a GitHub Action script that lives in the target
repo and runs on PRs touching .signatures/cla/. It compares the PR against the
repo's current CLA.md:
- signature file body ===
CLA.mdbody (verbatim) agreement_version===CLA.mdfrontmatterversionattestation==="I, @<handle>, agree to the following <name> Contributor License Agreement, version <version>."(with handle/name/version matching the other checks)- filename stem === commit author handle (lowercased)
- PR touches exactly one file under
.signatures/cla/
Sigil hosts and maintains this script (shipped as a reusable Action, to be authored later in a subdir of this repo) but does not run it. The split is deliberate: Sigil is UI + PR-opener, the repo's CI is the gate. This keeps "source of truth is the repo" honest - a maintainer who never points anyone at withsigil.eu can still accept signatures via the same Action.
From deno.json:
deno task dev- Vite dev server with HMRdeno task build- produces_fresh/for productiondeno task start- runs the production build (deno serve -A _fresh/server.js)deno task check-deno fmt --check,deno lint,deno checkdeno task update- runsjsr:@fresh/updateto upgrade Fresh
No test task yet. Single-file test: deno test path/to/file_test.ts; filter:
deno test --filter "name".
GitHub Actions runs deno task build and deploys _fresh/ to Deno Deploy
(scale-to-zero).
Neobrutalism - hard 2.5px black strokes, 6px 6px 0 #111114 offset shadows, no
border-radius, yellow #FFD400 / navy #001E62 / red #F80035 / green
#007A3D pigments for state; Archivo Black (display), Source Serif 4 (CLA
body), JetBrains Mono (labels), Inter (UI). Phosphor duotone icons. Ported from
the Claude Design handoff; design tokens live in assets/styles.css (@theme
block) plus the custom brutalism CSS below it.
The signing page lives at routes/cla/[forge]/[owner]/[repo].tsx with all
interactive surface in islands/SignBox.tsx. Demo content (Realm CLA, user
@benjick) is hardcoded in the island - real data wiring (OAuth + GitHub API
fetch of CLA.md and signature state) is TBD. A dev bar at the top of the
island lets you force any state; keyboard 1–7 also switches states. Remove
for production.
loggedOut → sign in · loggedIn → agree + open sign PR · resignNeeded →
banner + compare + re-sign · submitting → step-by-step animation of the fork
- commit + PR flow ·
signed→ PR receipt + artifact details + revoke link ·revoke→ confirm UI with reason textarea (reason goes to PR body, not frontmatter) ·revoking→ step-by-step animation of the delete + commit + PR flow.
After revocation merges, the user re-enters loggedIn on next page load -
"revoked" is not a distinct terminal state.