Overflow is a cooperative ledger for open-source work. A repository sponsor offers work, an outside contributor closes it through GitHub, and Overflow records a settled credit transfer with auditable proof.
Overflow is already running at https://overflow.nitjsefni.eu. Pointing you at that instance is what this repository is for. You do not need to deploy anything to use Overflow — sign in there and join the ledger that already exists. The setup instructions further down build a development environment for changing Overflow itself; they are not the way to use it.
Nitjsefnie/Overflow is itself registered in that instance, and the issues in this tracker are materialized there, so the mechanism described below can be watched working on this repository itself.
- Sign in. Open https://overflow.nitjsefni.eu and choose Sign in with GitHub. That is the whole account setup — there is nothing to install and nothing to configure.
- Register a repository, catalogs and all, on one form. Register a repository takes the repository and both of its catalogs and submits them together. Bring a public repository you administer. Registration writes to it: Overflow creates the catalog labels there and installs its webhook. What the ledger records is the reference for what a catalog has to contain. Catalogs can be changed later — on the same page, or over the API — and a change never re-prices work that has already settled.
- Offer work, then settle it. Apply an opening label when you file an issue. After the closing pull request's final commit and before you merge it, apply an actual-catalog label and post a comment naming that label — as the sponsor; nobody else's labels or comments price your repository's work, and a comment edited after the merge window closes no longer counts. Those are the labels Overflow created for you in step 2. What the ledger records states the evidence each label has to satisfy, and Scoring and calibration says what it is worth.
- Read the ledger. A signed-in member gets Ledger, Issues, Settlements, Register a repository, Calibration and Rules.
Closing work needs no repository of your own. Take an issue in a repository that is already registered; the sections that follow are the terms the credit settles on, including what happens when you have not signed in yet.
Members can register repositories with an Overflow-issued API token. Account registration still happens manually in a browser through Sign in with GitHub; after that, repositories can be registered over the API. The existing web form remains unchanged and available — programmatic registration is an additional way to submit the same repository and catalogs.
Sign in, open Register a repository (/repositories/new), and use Generate
token in the Overflow API token panel. Copy the token when it appears: it is
shown only at generation and cannot be redisplayed after leaving or reloading
the page. The server stores only its hash, so it cannot recover the plaintext.
Each account has at most one active token. Regenerate token issues a new token and invalidates the previous one in the same step. Use regeneration if you lose the token or it leaks, and replace the credential in your scripts. Keep the token private; do not commit it.
The panel calls POST /api/tokens with the signed-in browser session cookie and
no request body. An API token alone cannot mint or regenerate a token. Because
the session cookie is the only credential, the endpoint is same-origin only: the
request must carry an Origin header equal to the origin of APP_URL (its
scheme, host and port; any path is ignored), and it must either send no body or
declare Content-Type: application/json. Success is HTTP 201 with
{ "token": "<new-token>", "createdAt": "<ISO-8601 timestamp>" }.
Failures use { "error": { "code": "...", "message": "..." } }:
| HTTP | Code | Exact message | Meaning / next step |
|---|---|---|---|
| 401 | UNAUTHENTICATED |
Sign in is required. |
Sign in through GitHub in the browser. |
| 403 | FORBIDDEN |
The request origin is not allowed. |
The request carried no Origin header or one that is not the origin of APP_URL. Mint the token from the Overflow page in the browser. |
| 415 | UNSUPPORTED_MEDIA_TYPE |
The request must use the application/json content type. |
The request declared a Content-Type that is not application/json. Send no Content-Type at all, or send application/json. |
| 500 | MISCONFIGURED |
The server is not configured to accept this request. |
The deployment's APP_URL is missing or malformed, so it cannot recognize its own origin. Fix the server configuration; nothing about the request will help. |
| 502 | UPSTREAM_FAILURE |
Unable to issue an API token. |
Session lookup or token storage failed; retry when the service recovers. |
Send POST /api/repositories with Authorization: Bearer <token> and
Content-Type: application/json. Use the Overflow-issued token; registration
uses the account's stored GitHub OAuth credential for its GitHub operations.
The repository must be public, and that account must have GitHub administrator
permission for it. Registration creates the catalog labels and installs a webhook.
A bearer-token request is exempt from the origin check — a script is not a
browser and sends no Origin header — but it is not exempt from the content
type: Content-Type: application/json is required either way. The same endpoint
reached with a browser session cookie instead of a bearer token is
same-origin only, so its Origin must equal the origin of APP_URL.
The JSON body contains exactly these required fields. Extra fields, including extra fields inside label objects, are rejected.
| Field | Type and requirements |
|---|---|
repositoryUrl |
String: one owner/name or canonical GitHub repository URL. |
openingName |
Nonblank string: opening catalog display name. |
actualName |
Nonblank string: actual catalog display name. |
openingLabels |
Nonempty array of { "label": string, "comparisonPoints": number, "reservePoints": number }. Both point values must be integers from 1 through 10. |
actualLabels |
Array of { "label": string, "points": number } with exactly ten entries, covering every integer from 1 through 10 exactly once. |
All label text must be nonblank and unique across both catalogs. Opening labels can use any names and need not cover every point. Do not shorten the actual catalog to S/M/L: missing points cause rejection.
Replace <overflow-origin> with the origin of the Overflow instance you use
(scheme and authority, without a trailing slash), <your-token> with the token
from its panel, and your-org/your-repository with a public repository you
administer that is not already registered. Then run this complete example:
OVERFLOW_ORIGIN='<overflow-origin>'
OVERFLOW_API_TOKEN='<your-token>'
curl --include --request POST "${OVERFLOW_ORIGIN}/api/repositories" \
--header "Authorization: Bearer ${OVERFLOW_API_TOKEN}" \
--header 'Content-Type: application/json' \
--data-binary @- <<'JSON'
{
"repositoryUrl": "your-org/your-repository",
"openingName": "Estimated scope",
"actualName": "Delivered difficulty",
"openingLabels": [
{ "label": "offered: small", "comparisonPoints": 2, "reservePoints": 2 },
{ "label": "offered: medium", "comparisonPoints": 5, "reservePoints": 5 },
{ "label": "offered: large", "comparisonPoints": 8, "reservePoints": 8 }
],
"actualLabels": [
{ "label": "settled: 1", "points": 1 },
{ "label": "settled: 2", "points": 2 },
{ "label": "settled: 3", "points": 3 },
{ "label": "settled: 4", "points": 4 },
{ "label": "settled: 5", "points": 5 },
{ "label": "settled: 6", "points": 6 },
{ "label": "settled: 7", "points": 7 },
{ "label": "settled: 8", "points": 8 },
{ "label": "settled: 9", "points": 9 },
{ "label": "settled: 10", "points": 10 }
]
}
JSONSuccess is HTTP 201. Example body (identifiers vary):
{
"repository": {
"id": "<repository-id>",
"githubRepositoryId": 123456789,
"ownerName": "your-org/your-repository",
"sponsorId": "<account-id>",
"visibility": "PUBLIC",
"githubWebhookId": 987654321
},
"initialImportScheduled": true
}initialImportScheduled reports whether the import of the work that already
exists in the repository was queued, not whether it has finished: the import
runs after the response, and the issues appear once it does. If it is false,
the repository is still registered but nothing is queued; the periodic repair
sweep brings its existing work in later, or arrange a
reconciliation yourself. Registering the repository again is
never the remedy.
Errors have { "error": { "code": "...", "message": "..." } }. Match the HTTP
status and code, then use the message to distinguish causes:
| HTTP | Code | Exact message | Meaning / next step |
|---|---|---|---|
| 400 | INVALID_REQUEST |
Invalid repository registration request. |
Invalid JSON, missing or extra fields, or wrong field types. Correct the body. |
| 400 | INVALID_INPUT |
Submit one GitHub repository as owner/name or a canonical GitHub URL. |
Correct the repository reference. |
| 400 | INVALID_INPUT |
The repository is missing the difficulty labels <labels>. Create them on GitHub, then register again. |
The repository's existing labels do not include every label the submitted catalog names; <labels> is the backticked list of the missing ones. Create those labels on GitHub, then register again. |
| 400 | INVALID_INPUT |
Catalog validation message listed below. | Correct the catalog names, labels, or points. |
| 401 | UNAUTHENTICATED |
The supplied API token was not accepted. |
The bearer credential has an invalid token format or is unknown (including a revoked token). Check the copied token or generate a replacement in the browser. |
| 401 | UNAUTHENTICATED |
Sign in is required. |
No recognized bearer credential and no signed-in session. Supply the bearer header or sign in. |
| 401 | GITHUB_CREDENTIALS |
GitHub rejected the authorization Overflow holds for this account (HTTP 401) while trying to <step>. To refresh the authorization, sign out of Overflow and sign in again with GitHub, then retry registration. |
GitHub rejected the stored GitHub authorization for the account (expired or revoked); the account's Overflow session is fine. Refresh the authorization by signing out and back in, then retry the registration. |
| 403 | FORBIDDEN |
The request origin is not allowed. |
A browser (session-cookie) request carried no Origin header or one that is not the origin of APP_URL. A bearer-token request never reaches this: its origin is not consulted. |
| 403 | FORBIDDEN |
The account is not eligible to register repositories. |
The account is banned or recalibrating. Resolve the account restriction; regenerating the token does not remove it. |
| 403 | FORBIDDEN |
Only public GitHub repositories can be registered. |
Choose a public repository. |
| 403 | FORBIDDEN |
GitHub administrator permission is required for the submitted repository. |
Use an account with administrator permission for that repository. |
| 403 | GITHUB_ACCESS |
GitHub refused to <step> (HTTP 403). GitHub answers 403 both when the Overflow OAuth application is not yet authorized and when it is temporarily limiting requests, and this response carries nothing that separates the two causes. Wait a minute and retry registration before changing anything. <cause> Review Overflow's authorization at https://github.com/settings/applications, then retry registration. |
GitHub refused a setup step without rate-limit evidence, so the answer cannot separate a missing Overflow OAuth authorization from a temporary limit; wait a minute and retry first. <step> is the lookup, label-read, or webhook-create step that died, and <cause> names the missing authorization — for an organization-owned repository, the owner approval the Overflow application needs there. |
| 403 | GITHUB_ACCESS |
GitHub answered 404 for the request to <step>. GitHub returns 404 rather than 403 when it will not reveal a resource, which can indicate missing authorization. The repository may also have been renamed, moved or deleted<since it was looked up>. <cause> Review Overflow's authorization at https://github.com/settings/applications, then retry registration. |
GitHub hid the resource the setup step asked for, which it does instead of refusing it; treat the step as unauthorized until checked. <since it was looked up> appears when the repository had already been looked up, and <cause> again names the missing authorization, with the organization-owned variant as above. |
| 409 | CONFLICT |
This GitHub repository is already registered. |
Use the existing registration. |
| 409 | CONFLICT |
The GitHub path <owner/name> is claimed by a different registration. The submitted repository is not registered, and it cannot be registered while another registration holds that path. |
The submitted repository has never been registered, but another registration holds its owner/name. Retrying repeats the same collision; the registration holding the path has to be resolved first. |
| 409 | CONFLICT |
The GitHub webhook created for the submitted repository collided with one a different registration already records. The submitted repository is not registered. Registering again requests a new webhook from GitHub, so retry once before treating this as stored state that has to be resolved. |
The submitted repository has never been registered. The collision is on the webhook id GitHub returned for the hook this attempt created, and registering again requests another webhook, so retry once first. Only a collision that repeats points at stored state that has to be resolved. |
| 415 | UNSUPPORTED_MEDIA_TYPE |
The request must use the application/json content type. |
The request declared a Content-Type that is not application/json. This applies to bearer-token requests too, and is answered before the token is looked up. |
| 429 | GITHUB_RATE_LIMITED |
GitHub rate-limited the request to <step> (HTTP <status>). Please retry registration later. |
GitHub limited a setup step. <status> is 429, or 403 carrying rate-limit evidence; GitHub may append a Retry after <N> seconds. sentence when it supplies a delay, and the message ends with the retry instruction either way. Wait out the delay if given, then retry. |
| 500 | MISCONFIGURED |
The server is not configured to accept this request. |
The deployment's APP_URL is missing or malformed. Only a browser request reaches this; a bearer-token request does not read APP_URL. |
| 502 | UPSTREAM_FAILURE |
Unable to initialize repository registration. |
Credential/session lookup or registration setup failed; check service configuration and account GitHub access before retrying. |
| 502 | UPSTREAM_FAILURE |
Unable to retrieve the submitted GitHub repository. |
GitHub repository lookup failed; check the reference, access, and GitHub availability. |
| 502 | UPSTREAM_FAILURE |
Unable to read the repository difficulty labels on GitHub. |
Reading the repository's existing labels failed after the repository itself was found; check GitHub access and availability. |
| 502 | UPSTREAM_FAILURE |
Unable to create the repository webhook on GitHub. |
Creating the webhook failed after the repository was found and its labels verified; check GitHub access and availability. |
| 502 | UPSTREAM_FAILURE |
Unable to save the repository registration. |
Database lookup or saving the registration failed; check service health before retrying. |
| 503 | ROLLBACK_INCOMPLETE |
The repository registration could not be saved, and the webhook Overflow created for it could not be deleted on GitHub. Nothing was registered; retry the registration, and a later successful registration or unregistration removes the abandoned webhook. |
The save failed and the compensating webhook deletion failed too, so a webhook Overflow created still exists on GitHub. Nothing was registered and nothing is lost by retrying; the recorded webhook is cleaned up by a later successful registration or unregistration. |
Angle-bracketed text in the Exact message column is a value substituted at runtime.
Catalog validation returns one of these exact INVALID_INPUT messages:
Display names must not be empty.At least one opening label is required.Opening label text must not be empty.Difficulty label text must be unique.Opening point mappings must be integers from one through ten.Actual label text must not be empty.Difficulty label text must be unique across catalogs.Actual point mappings must be integers from one through ten.Actual point mappings must be unique.Actual labels must cover points one through ten exactly once.
Authentication runs before body validation. A recognized bearer credential takes precedence over the browser cookie: a rejected token is not rescued by a valid session. An absent or malformed bearer header falls back to cookie authentication, which continues to serve the web form.
A repository's difficulty catalog is a versioned series, not a fixed choice.
PATCH /api/repositories accepts the same body a registration takes and appends
the submitted catalog as the repository's next catalog version; the browser form
on the Register a repository page does the same. The version begins governing
at the moment of the change, so:
- closures whose evidence window closed before the change keep resolving at their recorded figures, and
- closures whose evidence window closes after the change are priced by the new catalog.
A submission identical to the current catalog changes nothing and reports it.
Errors have the same shape as the registration errors above; the change path
answers CONFLICT for an unregistered repository, and FORBIDDEN for anyone
but the repository's sponsor or an account that is not eligible to change
repository catalogs.
Each page a signed-in member reads has a GET endpoint answering the same JSON
the page renders: the dashboard behind the Ledger page, the issues board, the
settlement history, a settlement's proof, and calibration. They use the same
Overflow-issued ovf_ tokens as registration, sent as
Authorization: Bearer <token>; a browser can call them with its signed-in
session cookie instead. The credential rules are the ones registration states:
a recognized bearer credential takes precedence over the cookie, a rejected
token is not rescued by a valid session, and an absent or malformed bearer
header falls back to the cookie. Unlike the registration endpoints, these reads
are answered without an origin check — a programmatic GET sends no Origin
header at all, so guarding them would reject every script client. They take no
request body.
Every response is scoped to the authenticated account: each endpoint queries with the credential's account id, and the settlement proof is answered only to a caller who is a party to that settlement.
| Endpoint | Parameters | Success body (HTTP 200) |
|---|---|---|
GET /api/dashboard |
None. | The dashboard projection the Ledger page renders. Each entry of registeredRepositories carries reconciliationLastFailureAt as an ISO 8601 string, or null when there is no recorded failure. |
GET /api/issues |
Query parameters repository, openingLabel, claimState, all optional. |
An array of the issue projections the board renders. |
GET /api/settlements |
None. | An array of the settlement-history rows the page renders. |
GET /api/settlements/<id> |
Path parameter id. |
{ "settlement": <settlement proof projection>, "corrections": <correction requests raised against the settlement, or null> }. |
GET /api/calibration |
None. | { "comparison": <calibration comparison>, "selfWork": <self-work calibration rows, or null> }. |
The field lists of these projections are the server's compiled types, not a schema this document maintains; this section records the endpoints, their parameters, and their status behavior.
On /api/issues, a filter is applied only when the request names exactly one
value for it — a parameter named more than once is left unset. claimState
understands CLAIMED and ALL; anything unrecognized, including no value at
all, reads the unclaimed board (OPEN).
Two responses degrade rather than fail. In the settlement proof, corrections
is null when the correction history could not be read, and the settlement
itself is still answered; in the calibration response, selfWork is null
under the same terms and the comparison is still answered. The pages render the
same degradation, so neither null is an API-only shape.
Both list reads are capped at the most recent 200 rows — the settlement
history on /api/settlements and the selfWork list on /api/calibration —
mirroring what the pages render, so a capped list is not distinguishable from
complete history.
Success is HTTP 200. Errors use the registration error envelope,
{ "error": { "code": "...", "message": "..." } }. Match the HTTP status and
code, then use the message to distinguish causes:
| HTTP | Code | Exact message | Meaning / next step |
|---|---|---|---|
| 401 | UNAUTHENTICATED |
The supplied API token was not accepted. |
The bearer credential has an invalid token format or is unknown (including a revoked token). Check the copied token or generate a replacement in the browser. |
| 401 | UNAUTHENTICATED |
Sign in is required. |
No recognized bearer credential and no signed-in session. Supply the bearer header or sign in. |
| 403 | FORBIDDEN |
A member account is required. |
The credential resolved to an account that no longer exists: the member gate re-reads the account's role from the database at request time, so a session or token outliving its account is refused. |
| 404 | NOT_FOUND |
Settlement proof is not available. |
(GET /api/settlements/<id>) No settlement with this id, or the caller is not a party to it. Both are the same refusal. |
| 502 | UPSTREAM_FAILURE |
Unable to authorize the member request. |
Credential lookup or the role re-read failed; retry when the service recovers. |
| 502 | UPSTREAM_FAILURE |
Unable to load the eligible issues. |
(GET /api/issues) The read behind the endpoint failed; retry when the service recovers. |
| 502 | UPSTREAM_FAILURE |
Unable to load the settlement history. |
(GET /api/settlements) The read behind the endpoint failed; retry when the service recovers. |
| 502 | UPSTREAM_FAILURE |
Unable to load the settlement proof. |
(GET /api/settlements/<id>) The read behind the endpoint failed; retry when the service recovers. |
| 502 | UPSTREAM_FAILURE |
Unable to load the calibration comparison. |
(GET /api/calibration) The read behind the endpoint failed; retry when the service recovers. |
| 502 | UPSTREAM_FAILURE |
Unable to load the dashboard. |
(GET /api/dashboard) The read behind the endpoint failed; retry when the service recovers. |
The API above is also exposed as MCP tools over a single endpoint,
POST /api/mcp, so an agent harness can read the ledger and drive the
moderation and correction flows without hand-rolling HTTP calls against the
page endpoints.
Authentication uses the same Overflow-issued ovf_ tokens, sent as
Authorization: Bearer <token>. Browser sessions work for the reads, but
token auth is the intended credential: a cookie-authenticated write through
MCP is refused, because the synthesized internal calls carry no Origin
header for the same-origin guard to check.
The transport is stateless streamable HTTP — one JSON-RPC request per
POST, and no session state between calls. initialize answers with
protocol version 2025-06-18; a notification (a JSON-RPC request with no
id) is answered with an empty HTTP 202.
| Tool | Purpose |
|---|---|
issues_board |
List the eligible issues on the claim board, optionally filtered by repository, opening label or claim state. |
settlements_list |
List the calling account's priced settlements. |
settlement_get |
Fetch one settlement's proof by its id. |
calibration_compare |
Fetch the calibration comparison for the calling account. |
dashboard_summary |
Fetch the calling account's dashboard summary. |
moderation_queue |
List the account audits currently open in the moderation queue. |
audit_open |
Open an account audit over a calibration sample. |
audit_decide |
Dismiss or substantiate an open account audit. |
correction_open |
Request a correction to a priced settlement or calibration outcome. |
correction_decide |
Grant or decline a settlement correction request. |
Tool errors are not transport errors: the wrapped endpoint's
{ "error": { "code": "...", "message": "..." } } envelope comes back as the
result's text content with isError: true, so read the text to tell a
validation refusal from an upstream outage.
On issues_board, a filter argument that is not a string is dropped from the
query rather than rejected, so an omitted argument and a malformed one read
the same board.
- GitHub OAuth signs a member in at
/api/auth/callback/github. - Repository registration is explicit and one at a time. The submitted
owner/nameor canonicalhttps://github.com/owner/nameURL must be a public repository, and the signed-in person must have GitHub administrator permission for it. - Each repository chooses its own opening catalog. S/M/L is allowed, but so are arbitrary labels such as
moonlit ridge,risk: high, or anything else the repository understands. Each opening label carries comparison and reserve points from 1 through 10. - Every actual catalog has exactly one editable mapping for each point from 1 through 10. The labels are repository-defined; the point mapping is the common settlement scale.
- The dashboard uses materialized ledger entries and balances. Available headroom is
settled balance − reserve pointsfor open issues assigned to outside contributors, and negative headroom remains visible. This release enforces no credit floor and exposes no floor configuration; optional group floors await a later idempotent assignment-enforcement design.
Closing-link evidence comes only from GitHub GraphQL closedByPullRequestsReferences. Opening difficulty is reconstructed from the earliest configured label that the repository sponsor applied before the first assignment. Settled difficulty requires exactly one active actual-catalog label, applied by the sponsor between the closing pull request's final commit and merge, plus a nonblank sponsor comment naming that label. Only the sponsor prices work; being the issue's author grants no pricing authority. Work completed by the sponsor is self-work calibration, not a settlement. Pull-request labels never price work.
A 15-minute tolerance applies to label and comment timing; the settlement window closes 15 minutes after merge. A rationale comment edited after that close does not count. The earliest qualifying comment at or after the standing label is used, including when a label is reapplied; if none exists, a comment up to 15 minutes before that label can count. Overflow retains the accepted event/comment identifiers and timestamps, the exact merge commit OID, and the diff fingerprint so every scoring input is reproducible.
Contributors and moderators are identified by their immutable GitHub account id; a GitHub login is displayed but never decides who is credited or who is a moderator.
For an outside contributor, settled credits are:
credits = max(0, actual points − distinct review rounds)
There is no churn metric. Review rounds are the distinct changes-requested reviews submitted before merge, counted as they stood when the pull request merged: a review dismissed after the merge still counts, and one dismissed before the merge does not. A dismissal exactly at merge also leaves the round counted; no timing tolerance applies to reviews. A dismissed review counts only if its dismissal history establishes that it requested changes; missing history or an unknown previous state does not count.
Calibration compares paired self-work samples with outsider settlements; it does not measure activity retention. Self-work is useful calibration evidence, but it creates no ledger entry. If an outside contributor has not signed in yet, their completed work remains an unclaimed settlement until their GitHub identity is claimed.
Moderation is account-level and evidence-led:
audit → warn → recalibrate → ban
Open an audit only with the required paired samples, warn when the record supports it, require recalibration before reactivation, and ban only after confirmed patterns persist.
Each instance keeps its own ledger. Balances, reserves, settlements, proof records and calibration history live in that instance's own PostgreSQL database, and nothing in this codebase moves them between deployments. A second instance therefore starts empty and stays private to itself: no registered repositories, no counterpart to settle with, and no credit that anyone else can see or honour. Signing in at https://overflow.nitjsefni.eu is what puts your work in a ledger other people are already reading.
These steps stand up a local copy of the application against a local PostgreSQL database.
-
Copy
.env.exampleto.envand replace every placeholder.AUTH_SECRETcan be generated withnpx auth secret;TOKEN_ENCRYPTION_KEYmust be an unpadded base64url encoding of 32 random bytes. -
Use an already-installed PostgreSQL 17 server or start the local Compose service:
docker compose up -d postgres docker compose ps
That service publishes PostgreSQL on loopback only, and its password is a committed, well-known string;
POSTGRES_HOST_BINDwidens that binding, so any address other than a loopback one publishes a database with known credentials to everything that can route to this machine. To reach it from another host, forward the loopback port over SSH —ssh -L 5432:127.0.0.1:5432 <host>— instead of widening the bind address. -
Point
DATABASE_URLat that database, then install and migrate:pnpm install --frozen-lockfile pnpm db:migrate
-
Start the application:
pnpm dev
Useful verification commands (the geometry check needs a Chrome/Chromium binary and, when it spawns its own server, DATABASE_URL):
pnpm test --run
pnpm lint
pnpm typecheck
pnpm build
node scripts/check-page-geometry.mjsCONTRIBUTING.md covers the rest of the development surface, including the conventions that reject work silently.
This section is operator configuration for a deployment you run yourself; on the running instance it is already done. https://<public-host> is a placeholder for your own deployment's origin — replace it with that origin, and do not read it as an address to visit.
Create a GitHub OAuth application and a public HTTPS webhook endpoint. Configure the OAuth app's callback URL as https://<public-host>/api/auth/callback/github, and set these values in .env:
APP_URL=https://<public-host>
GITHUB_WEBHOOK_URL=https://<public-host>/api/github/webhooks
GITHUB_WEBHOOK_SECRET=<the-webhook-secret-configured-in-github>GitHub must be able to reach the webhook URL over public HTTPS. Keep the webhook secret private and set the same value in GitHub and GITHUB_WEBHOOK_SECRET. The webhook endpoint rejects deliveries larger than 25 MiB with HTTP 413.
The production deployment runs the application as a dedicated unprivileged system account rather than as root, under a systemd unit that keeps the filesystem read-only apart from the one cache directory Next writes at runtime. deploy/overflow.service is that unit, and deploy/README.md is the procedure that stands it up on a host, deploys a new revision under it, and rolls it back. tests/deploy/unit-file.test.ts fails if the unit loses any of that hardening.
A repository is folded from a durable queue rather than inside the request that noticed it had fallen behind. A GitHub webhook delivery records a reconciliation job for the repository and answers immediately; when admission is available, an in-process worker normally claims that job within seconds, folds the repository, and clears the job. After claiming, a GraphQL budget hold can defer that repository to its sponsor's reset time, releasing the lease so the worker can continue to other repositories. A fold that throws is retried on the job after a minute, five, fifteen and an hour, and a repository that exhausts those retries stays visibly failed rather than disappearing from the queue. Registering a repository records the same kind of job, because the work already in the repository predates the webhook.
A sweep runs at startup and every six hours, and offers every active repository to that queue. A repository owns one job row, so a repository already queued keeps its place and its backoff, and one whose retries were exhausted is revived. Missed webhook deliveries and repositories left failed by a GitHub outage are therefore offered for repair within six hours without manual intervention; completion can take longer because of budget holds, queued work, or further GitHub failures.
GITHUB_GRAPHQL_BUDGET_RESERVE controls reconciliation's admission threshold. It defaults to 500 points; 0 disables the budget hold. Set a nonnegative integer: missing, blank or malformed values fall back to 500. A very large valid value deliberately makes the threshold restrictive, potentially holding every pass with a known current reading. Under the repository lock, after resolving its sponsor and before starting a run, the fold holds when that sponsor's recorded remaining balance is below this threshold. A held job is deferred to that reading's reset time without consuming retries; its outcome is BUDGET_HELD, distinct from a cooldown's DEFERRED. This admission check also applies to direct folds. An absent or expired reading, or an unavailable observer, permits admission until a usable reading is available.
This is an admission threshold, not a guaranteed remaining balance. An admitted pass may fetch many pull requests and paginate without a point ceiling, so it can spend past the reserve. The threshold stops new passes; it does not cancel requests within an admitted pass.
The GraphQL budget panel at the end of the moderator page (/moderation) displays one entry per known quota owner: the account's GitHub login when already available from the moderator roster, otherwise its account ID, followed by the recorded remaining balance, optional limit, reserve, reset time, observation time and hold state. Each sponsor account has its own OAuth quota; its readings and transitions are keyed by account ID, never by a credential. Unowned gateways record nothing. Within each owner's newest reset window the store retains the lowest balance and rejects older windows, so delayed responses cannot erase a known hold. Observations are process-local and disappear on restart; they are not shared across application instances. No owners observed yet is displayed separately from a known owner's unobserved or held budget.
For the production systemd deployment, edit GITHUB_GRAPHQL_BUDGET_RESERVE in /etc/overflow/overflow.env, then run sudo systemctl restart overflow.service to apply the new environment. In local development, update .env and restart pnpm dev. Restarting also clears the process-local observation, so the panel initially reports an unobserved budget.
Setting OVERFLOW_DISABLE_RECONCILIATION_SWEEP to any non-empty value turns off the worker as well as the sweep, which is the whole of automatic reconciliation: jobs still accumulate, and nothing drains them.
Run reconciliation explicitly when GitHub history must be re-read:
# Reconcile one explicit registered repository by owner/name.
pnpm reconcile --repository <owner>/<name>
# Reconcile every active registered repository.
pnpm reconcileReconciliation materializes issues, linked pull requests, settlement proof, self-work calibration, and unclaimed contributor records. PostgreSQL serializes each repository from snapshot collection through materialization. Eligibility is reconstructed at merge time from immutable moderation history, so a later sanction cannot rewrite eligible historical facts.
New registrations subscribe to issues, pull_request, pull_request_review,
and issue_comment. Comment creation, editing and deletion each invalidate the
payload's issue subject and queue repository reconciliation through the same path
as issue events, regardless of the comment text, author or issue state.
The fold's pricing, author/edit evidence rules and fifteen-minute grace are unchanged.
Deploy and verify the comment-capable release before upgrading existing hooks.
With the deployment's DATABASE_URL, TOKEN_ENCRYPTION_KEY, and original
GITHUB_WEBHOOK_SECRET loaded, run:
pnpm webhooks:upgradeThis enumerates active registrations, decrypts each sponsor's OAuth token, and resolves the current public repository by its immutable GitHub ID. It updates the persisted hook ID at that repository's current owner/name. GitHub's additive webhook update retains unrelated subscriptions; the command retains the hook's callback configuration and active state, and resends the original secret because GitHub warns that omitting it can remove it. Do not substitute a new secret or run this while someone is editing hook configuration. Hooks already covering all required events (including wildcard hooks) need no write.
Each JSON outcome identifies the registration by its local ID, reports
subscription separately from queue, and names a sanitized failure stage.
VERIFIED means the subscription was confirmed; QUEUED means a full upstream
refresh was durably requested through the existing rederivation mechanism, not
that the fold has finished. Historical missed comments have no known subject to
invalidate, so this administrative repair bypasses incremental checkpoints.
A hook that was disabled stays disabled. Every verified run requests full repair,
including reruns after a queue failure.
The summary counts succeeded and failed registrations. Exit 0 requires every
registration to succeed; exit 1 indicates a failed step; exit 2 indicates invalid
arguments. Missing tokens, missing/inaccessible hooks, lost admin rights, private
repositories and mismatched IDs remain failures. Restore the sponsor's access or
correct the reported registration problem, then rerun. UPGRADE_FAILED indicates
configuration or enumeration failed before a complete summary was available.
Retain the JSON output and real exit status with the deployment record, as the ordinary deployment procedure does. The startup sweep reconciles evidence but does not upgrade subscriptions.
GitHub Actions runs the complete gate on pushes to main, pull requests targeting main, and manual dispatches. The gate uses the pinned Node and pnpm versions, applies migrations to PostgreSQL 17, then runs pnpm test --run, pnpm lint, pnpm typecheck, and pnpm build, finishing with the page-geometry check, node scripts/check-page-geometry.mjs, against the built output. A separate actionlint/zizmor workflow validates and security-checks the workflow definitions themselves. All actions are commit-pinned and checkout credentials are not persisted.
.env.example documents every required setting:
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
AUTH_SECRET |
Auth.js session signing secret |
AUTH_GITHUB_ID, AUTH_GITHUB_SECRET |
GitHub OAuth application credentials |
TOKEN_ENCRYPTION_KEY |
OAuth-token encryption key |
APP_URL |
Public application URL; its origin is the only one browser mutations may come from, and a missing or malformed value refuses every one of them |
GITHUB_WEBHOOK_URL, GITHUB_WEBHOOK_SECRET |
Public GitHub webhook URL and shared secret |
MODERATOR_GITHUB_USER_IDS |
Comma-separated moderator GitHub account ids (gh api users/<login> --jq .id); replaces MODERATOR_GITHUB_LOGINS, which is no longer read |
GITHUB_GRAPHQL_BUDGET_RESERVE |
Optional GraphQL admission threshold for new worker passes; defaults to 500, malformed values fall back to 500, and 0 disables the hold. A very large value is deliberately restrictive; see Reconciliation for scope and restart instructions. |
Use placeholders only in checked-in configuration. Never commit OAuth credentials, webhook secrets, database passwords, or encryption keys.
MIT — see LICENSE. Contributions are accepted under the same terms.