Self-hosted Node.js app that gates pull requests behind a contributor application form. A GitHub App watches pull_request.opened events on linked repos. If the PR author hasn't been approved for that project, the App closes the PR with a friendly comment pointing to a public application page. Maintainers triage applications in a dashboard. On approval, previously-closed PRs are automatically reopened.
- Next.js 15 (App Router) + Prisma + SQLite
- Auth.js v5 (GitHub OAuth) for sign-in
- GitHub App for repo automation (PR close/reopen/comment/label)
- Tailwind v4 + shadcn-style UI primitives
- Vitest for unit tests
- Docker + docker-compose for self-hosting
pnpm install
cp .env.example .env
# Fill in AUTH_SECRET (openssl rand -base64 32), AUTH_GITHUB_ID/SECRET
# (create an OAuth app at https://github.com/settings/developers)
# Add your GitHub login to SUPER_ADMINS so you can manage allowlist + setup.
pnpm exec prisma migrate dev
pnpm dev
# Open http://localhost:3000, sign in with GitHub.
# Visit /admin/setup to bootstrap the GitHub App via manifest.cp .env.example .env
# Fill AUTH_SECRET, AUTH_GITHUB_ID/SECRET, PUBLIC_BASE_URL, SUPER_ADMINS, SMTP_*.
# Update docker/Caddyfile with your domain.
docker compose -f docker/docker-compose.yml up -d
# 1) Visit https://your-domain.example/admin/setup as a super-admin.
# 2) Click the manifest button to create the GitHub App on github.com.
# 3) GitHub redirects back; copy the printed env block into .env.
# 4) docker compose -f docker/docker-compose.yml restart app.
# 5) Confirm at /admin. The App is now configured.The GitHub App is what gives the system permission to receive PR webhooks and to close/reopen/label PRs. It's separate from the OAuth App used for human sign-in. Set it up once per instance.
- The instance is reachable on a public URL (
PUBLIC_BASE_URLin.env). Local development with a tunnel likengrokworks too; just use the tunnel URL. - Your GitHub login is in
SUPER_ADMINSin.env. Sign in once so the flag gets applied.
Single-App mode (recommended): one GitHub App handles both human sign-in and repo automation. Leave
AUTH_GITHUB_ID/SECRETblank; the App's OAuthclient_id/secretare reused for sign-in. The manifest at/admin/setupregisters the right OAuth callback automatically.Two-App mode: if you want a separate OAuth App for sign-in, create one at https://github.com/settings/developers with callback
https://your-domain.example/api/auth/callback/githuband put its credentials inAUTH_GITHUB_ID/SECRET. Those take precedence over the App's OAuth credentials when set.
/admin/setup is a public page that shows the exact URLs you need. Open it once to copy them, then create the App at https://github.com/settings/apps/new (or under your org).
| Field on github.com | Value |
|---|---|
| Homepage URL | ${PUBLIC_BASE_URL} |
| Webhook URL | ${PUBLIC_BASE_URL}/api/github/webhook |
| Webhook secret | random string (openssl rand -hex 32), stored in GITHUB_APP_WEBHOOK_SECRET |
| Callback URL | ${PUBLIC_BASE_URL}/api/auth/callback/github |
Permissions:
- Repository: Pull requests: Read & write
- Repository: Issues: Read & write
- Repository: Metadata: Read (auto-selected)
- Account: Email addresses: Read (so the app can email applicants)
- Organization: Members: Read (optional, only for the auto-bypass-collaborators feature on org repos)
Subscribe to events: Pull request, Installation target, Installation repositories.
After creating the App:
- Generate a private key (downloads a
.pemfile). - Note the App ID and Client ID, and click Generate a new client secret.
- Fill in your
.env:GITHUB_APP_ID="..." GITHUB_APP_SLUG="..." # last URL segment of the App GITHUB_APP_CLIENT_ID="..." GITHUB_APP_CLIENT_SECRET="..." GITHUB_APP_WEBHOOK_SECRET="..." # PEM on a single line with literal \n between lines, in double quotes: GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n" # Single-App mode: leave AUTH_GITHUB_ID/SECRET blank.
- Restart the server.
/admin/setupshould now show a green "Configured" badge.
For every project that should be gated:
- Create the project from
/dashboard→ New project. Choose a slug (this becomes/p/<slug>). - In the project, go to Repos tab → click "Add repo via GitHub App". This sends you to GitHub to install the App. Pick Only select repositories and choose the repo(s).
- GitHub redirects you back to a "Link repositories" page in the dashboard. Tick the repos you want gated and Save selection.
- Done. Open a PR from a non-allowlisted GitHub account on that repo to test. The PR should be closed within a second or two with a comment pointing to
/p/<slug>.
| What | How |
|---|---|
| Webhooks reaching your server | GitHub App → Advanced → Recent Deliveries should show 200 responses. |
| Decision flow | Open a PR → in the dashboard, Applications queue should not show one (the user hasn't applied yet); a PrCheck row was created with status PENDING. |
| Public landing | Visit /p/<your-slug> while signed out; you should see the apply form gated behind a sign-in button. |
| Approve flow | Submit an application as a second test account, approve it from the dashboard. The PR you opened should reopen automatically and switch labels. |
The manifest requests exactly:
- Repository: Pull requests: read & write (close/reopen/label PRs)
- Repository: Issues: read & write (PR comments + label CRUD; PRs are issues for these endpoints)
- Repository: Metadata: read (required by GitHub for any installation)
- Organization: Members: read (used by the collaborator auto-bypass check)
Subscribed events: pull_request, installation, installation_repositories.
- "GitHub App not configured" after restart: the env loader is strict about the PEM format. Make sure the value in
.envis wrapped in double quotes and uses\nfor newlines (the setup callback gives you exactly the right format). - Webhook deliveries show 401 Invalid signature:
GITHUB_APP_WEBHOOK_SECRETdoesn't match. Re-check the value in.env. - PRs aren't being closed: confirm the repo is linked under the project's Repos tab (not just installed in the GitHub App). The webhook ignores PRs from repos that aren't linked.
- Collaborators are seeing their PRs blocked: toggle off the "Auto-bypass repository collaborators" option on the project settings page if you want to gate collaborators too, or leave it on if you want them to skip the gate. The check is cached for 5 minutes.
For repos where you can't install the GitHub App (no org admin rights, restrictive enterprise policies, etc.), the project supports a parallel ingestion path: drop two workflows into the repo and the gating runs from inside Actions, using the workflow's built-in GITHUB_TOKEN to take action on PRs.
Workflows authenticate using GitHub Actions' built-in OIDC token. Each request to the contribution-checker endpoints carries a short-lived JWT minted by GitHub (token.actions.githubusercontent.com). The server verifies the token's signature against GitHub's JWKS and trusts the repository and aud (audience) claims:
audmust equal${PUBLIC_BASE_URL}/p/<projectSlug>, which binds the token to a specific project on this instance.repositorymust match aReporow registered under that project (byowner/name).
There are no shared secrets to configure or rotate. A leaked token is useless after ~6 minutes and cannot be minted from outside the registered repo.
- In the dashboard, go to the project's Repos tab and add the repo by name (
owner/name). - Copy the two YAML files shown on the page into
.github/workflows/on the repo's default branch:contribution-check-gate.yml: runs on every PR (pull_request_target) and gates open/reopen.contribution-check-reconcile.yml: runs every 10 minutes; reopens PRs whose authors have since been approved.
- Push. The next PR is gated automatically.
The workflow YAMLs come pre-filled with ${PUBLIC_BASE_URL} and the project slug.
- Reopen latency: up to 10 minutes (the reconcile cron interval). The App reopens immediately on approval.
- Collaborator auto-bypass: not available in CI mode. List collaborators explicitly in the project's bypass handles, or extend the workflow to compute
isCollaboratorviagh api /repos/{}/collaborators/{}and include it in thecheck-prrequest body. - PR labels: the workflow needs
issues: writepermission (already in the YAML). Labels are created lazily by GitHub on first use.
The gate workflow uses pull_request_target so it has access to the OIDC token even for PRs from forks. We do not actions/checkout the PR head SHA; only the base branch's workflow file is executed. This avoids the standard pull_request_target injection vector.
GitHub repo GitHub App contribution-checker SQLite
│ │ │ │
│ PR opened ─────────────────▶ webhook delivery ──────▶│ decideForPR() ─────────┤
│ │ │ │
│ │◀── close PR + comment + label ◀──── PENDING ─────┤
│ │ │
│ user opens /p/<slug>, signs in, submits application ──▶ store ─────────────────▶
│ admin clicks Approve ────────────────────────────────▶ update + reopen PRs ───▶
│ │◀── reopen prior closed PRs of this user ──┤ │
decideForPR(repo, ghLogin, ghId) checks (in order):
- Manual decision for this
(project, ghLogin): if APPROVED/DENIED, that wins. - Bypass list: glob-matched against
Project.bypassHandles. Match → bypassed. - Repo collaborator check (if
Project.bypassCollabsis on): collaborators bypass. - Latest application for
(project, user): APPROVED → approved, DENIED + active cooldown → denied, else → pending.
- GitHub App webhook gates
pull_request.openedandpull_request.reopened. - Public per-project landing page at
/p/<slug>with sign-in-to-apply flow. - Schema-builder application form (text/textarea/url/select/checkbox).
- Reusable form templates (per user).
- Three-tier project roles: OWNER / ADMIN / REVIEWER.
- Super-admin allowlist for project creation.
- Audit log per project.
- Internal admin-only notes on each application.
- In-app notifications + SMTP email + outbound webhooks (HMAC-signed, with retries).
- Rate limiting on the public apply endpoint.
- Configurable per-project denial cooldown (or permanent).
- Manual approve/deny list for any GitHub login (no application required).
- Bypass list for bots (
*[bot]etc.) and optional auto-bypass for repo collaborators. - Configurable per-project labels for
pending,approved,denied. - Approval reopens previously-closed PRs by the same user across all linked repos.
- Revocation modal with optional "close their open PRs" toggle.
See .env.example. Highlights:
DATABASE_URL: SQLite file path (e.g.file:./data/contribution-checker.db)PUBLIC_BASE_URL: your canonical URL, used for emails/webhooks/redirectsAUTH_SECRET: Auth.js session secret (openssl rand -base64 32)AUTH_GITHUB_ID/SECRET: OAuth app credentials for human sign-in (separate from the GitHub App)GITHUB_APP_*: GitHub App credentials, populated by/admin/setupSUPER_ADMINS: comma-separated GitHub logins, granted super-admin on first sign-inPROJECT_CREATORS: comma-separated logins, granted project-creation rights on first sign-inSMTP_*: optional, SMTP transport for email notificationsVAULT_*: optional, source secrets from HashiCorp Vault (see below)
When VAULT_ADDR is set, the following values can be sourced from Vault
instead of plain env vars: GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY,
GITHUB_APP_WEBHOOK_SECRET, GITHUB_APP_CLIENT_ID,
GITHUB_APP_CLIENT_SECRET, GITHUB_APP_SLUG, AUTH_GITHUB_ID,
AUTH_GITHUB_SECRET, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS,
SMTP_FROM.
DATABASE_URL and AUTH_SECRET remain env-only: they're consumed
synchronously at module load (Prisma client and Auth.js cookie crypto), and
moving them behind Vault would require a wider refactor.
Set VAULT_AUTH_METHOD to one of:
token(default): provideVAULT_TOKEN. Easiest for dev and small deployments.approle: provideVAULT_APPROLE_ROLE_ID+VAULT_APPROLE_SECRET_ID(and optionallyVAULT_APPROLE_MOUNT, defaultapprole).
VAULT_NAMESPACE is supported for Vault Enterprise namespaces.
Each secret is mapped via its own env var: VAULT_<NAME>_PATH. Format:
secret/data/<path>[#<field>]
If #field is omitted, the resolver looks for a key named value.
Example KV v2 layout (one path per logical group):
$ vault kv put secret/cc/github \
app_id=12345 \
private_key=@./key.pem \
webhook_secret=$(openssl rand -hex 32) \
client_id=Iv1.xxx client_secret=yyy \
slug=my-contribution-checker
$ vault kv put secret/cc/smtp \
host=smtp.example.com port=587 \
user=mailer pass=... \
from="Contribution Checker <noreply@example.com>"
Then in the env:
VAULT_GITHUB_APP_ID_PATH=secret/data/cc/github#app_id
VAULT_GITHUB_APP_PRIVATE_KEY_PATH=secret/data/cc/github#private_key
VAULT_GITHUB_APP_WEBHOOK_SECRET_PATH=secret/data/cc/github#webhook_secret
VAULT_GITHUB_APP_CLIENT_ID_PATH=secret/data/cc/github#client_id
VAULT_GITHUB_APP_CLIENT_SECRET_PATH=secret/data/cc/github#client_secret
VAULT_GITHUB_APP_SLUG_PATH=secret/data/cc/github#slug
VAULT_SMTP_HOST_PATH=secret/data/cc/smtp#host
VAULT_SMTP_PORT_PATH=secret/data/cc/smtp#port
VAULT_SMTP_USER_PATH=secret/data/cc/smtp#user
VAULT_SMTP_PASS_PATH=secret/data/cc/smtp#password
VAULT_SMTP_FROM_PATH=secret/data/cc/smtp#from
- A secret with a
VAULT_<NAME>_PATHset is read from Vault on first access and cached forVAULT_CACHE_TTL_SECONDS(default 300). - Without a
VAULT_<NAME>_PATH, the resolver falls back toprocess.env[NAME]. - If Vault is unreachable for a secret whose path is configured, the consumer fails fast: the webhook handler returns 500 rather than proceeding with stale or missing credentials.
/admin/vaultshows per-secret resolution status (no values displayed).
A minimal Vault policy granting read on the paths above:
path "secret/data/cc/*" {
capabilities = ["read"]
}pnpm test # vitest
pnpm typecheck # tsc --noEmit
pnpm build # next build (also runs prisma generate)src/
├── app/
│ ├── (root)/ landing page + auth callbacks
│ ├── p/[slug]/ public project landing + apply form
│ ├── dashboard/ auth-gated user/admin dashboard
│ │ ├── projects/[id]/ per-project: overview, applications, decisions, repos, form, settings, audit
│ │ ├── notifications/ in-app notification inbox
│ │ └── templates/ user's saved form templates
│ ├── admin/ super-admin (allowlist, GitHub App setup)
│ └── api/
│ ├── auth/ Auth.js v5 handlers
│ └── github/ App webhook + post-install + manifest callback
├── lib/
│ ├── applications/ form schema, submission lifecycle, PR decision logic
│ ├── github/ Octokit App, PR actions, collaborator cache, webhook dispatch
│ ├── notifications/ in-app inbox, SMTP email, outbound webhooks
│ ├── auth.ts / auth.config.ts Auth.js setup (split for edge-safe middleware)
│ ├── authz.ts role-based authz helpers (requireProjectRole etc.)
│ ├── audit.ts append-only audit log writer
│ ├── ratelimit.ts DB-backed sliding-window rate limit
│ └── env.ts Zod-validated environment
└── components/
├── ui/ shadcn-style primitives (button, input, card, ...)
├── form-renderer.tsx shared form rendering for builder preview + public apply page
└── site-header.tsx global nav with notification bell
MIT.