11. Probe#
GET /projects/{owner}/{slug}/probe?pr=3
GET /projects/{owner}/{slug}/probe?branch=agent-b/credentials
GET /projects/{owner}/{slug}/probe?tag=v0.1.0
GET /projects/{owner}/{slug}/probe?pr=3&max_age=0
Scope project:read. The hub's read-only view of the project's forge
(GitHub in V1). The forge API host and the instance token come from the operator's
environment only (MAITY_FORGE_GITHUB_TOKEN; the host is fixed per forge
kind, api.github.com for GitHub): nothing a project member sets can
make the hub send its token anywhere else, and repository.url must be
on a configured forge host (§6.1). Which grant reads a project's
repository — the namespace owner's GitHub App installation, the owner's
own token, or this instance token for a public repository of the operator's
own namespaces only — is
§11.4; every grant is read-only, and its permissions are the limit of
what the project can observe. The hub caches an observation for
max_age seconds (default 60, 0 forces a fresh read) and never writes
to the forge.
{
"forge": "github",
"repository": "oliver-zehentleitner/maity",
"observed_at": "2026-10-21T13:35:00Z",
"fresh": true,
"pr": { "number": 3, "found": true, "url": "…", "title": "hub: skeleton", "state": "open", "draft": false,
"head_sha": "…", "base": "main", "mergeable": "conflicting",
"checks": "pending", "checks_detail": [ { "name": "ci / sqlite", "status": "success" } ],
"merged_at": null, "merge_sha": null },
"branch": null,
"tag": null
}
state: open, merged, closed; checks: success, failure,
pending, none (no runs — the "conflicting, so no CI" case is
mergeable: conflicting with checks: none), unknown. A pull request
the forge does not have is {number, found: false} and nothing else; it
is an answer, not an error. For branch:
{name, head_sha, found}, head_sha null when not found. For tag: {name, sha, found, observed_at},
the same shape as release.tag. Errors: probe_not_configured,
probe_unavailable (with the last observation in details.last when one
exists). The same reads feed report.observed,
release.pull_requests and the forge links on cards (§6.13, read
through the forge's issue endpoint for kind: issue), in the background after a report and on the
hub's own interval; an observation that changes a card's state emits
probe_observed. With the change ticker enabled the probe also reads
the merged pull requests of the default branch, their files and, for a
contract file, its text at the merge and the base (§17.4); a forge
webhook only makes it look sooner.
11.1 Outbound reads#
Every read the hub makes on its own — the probe with the card links it
observes and the import (§6.13, §6.14), the logo and banner (§11.2), the
Keep the Why export (§11.3) — follows one transport rule. A URL link
(kind: url) is never read, so no member-supplied URL becomes a
read:
httpson port 443 only. TLS is verified against the host name.- The host is fixed per read: the forge's API host (
api.github.comfor GitHub), the forge's image hosts (§11.2), or the host of the export's state URL. A forge grant (§11.4) goes to the API host only; an image host or an export host gets no token, no cookie and noAuthorizationheader. Every read carriesUser-Agent: maity-hub. - Redirects are followed only to
httpson the same host and port, at most 3; any other redirect ends the read as refused. A read of a project's repository is the exception: a redirect there (a renamed or moved repository) is not followed at all (§11.4). A state URL must therefore be the final one (a GitHub Pages site that moved to its own domain is named by that domain). - No private addresses: the hub resolves the host, refuses the read when
any resolved address is not globally routable — loopback, unspecified,
private (
10/8,172.16/12,192.168/16), shared (100.64/10), link-local (169.254/16with the cloud metadata address,fe80::/10), unique local (fc00::/7), multicast, reserved and documentation ranges, and the IPv4-mapped, 6to4 and NAT64 forms of any of them — and connects to the address it checked, never resolving twice. The forge hosts the operator configures are exempt (a self-hosted forge on a private network is the operator's choice); a host a project member or a repository names never is. - 10 seconds per read, connect to last byte. The body is read up to its cap and the read is refused at the first byte past it; the cap counts the decoded bytes when the answer is compressed.
- A failure is reported by a reason code, never by the upstream's body or error text.
The probe's fixed host satisfies the rule as it is; the rule matters for the reads whose host comes from data.
11.2 Logo and banner#
GET /projects/{owner}/{slug}/logo GET /projects/{owner}/{slug}/banner
PUT /projects/{owner}/{slug}/logo PUT /projects/{owner}/{slug}/banner
DELETE /projects/{owner}/{slug}/logo DELETE /projects/{owner}/{slug}/banner
GET is scope project:read. The dashboard shows each project with its
own image in place of Maity's logo. There are two sources, and the first
that has an image wins:
- uploaded — an image a human with
project:adminuploaded (below); - forge — the repository's image, read server-side through the forge reader;
- none —
not_found; the dashboard draws the monogram.
The logo and the banner are independent: a project may upload a logo and
keep the forge's banner. The hub serves either from its own origin; the
browser never loads an image from the forge. The project view says which
applies (logo, banner, §6.1).
From the forge.
| Forge | Square logo | Banner |
|---|---|---|
| GitHub | the owner's avatar, when the owner is an organization: owner.avatar_url of GET /repos/{owner}/{repo} with owner.type Organization (GitHub has no repository avatar; this is what https://github.com/<owner>.png shows). A repository a user owns has no forge logo: a person's photo is not a project's logo, and the dashboard draws the monogram unless one is uploaded |
the social preview: openGraphImageUrl of the GraphQL repository object, only when usesCustomOpenGraphImage is true (GitHub's generated card is not a banner) |
| GitLab | the project's avatar_url, else the namespace's |
none |
| Gitea, Forgejo (Codeberg) | the repository's avatar_url, else the owner's |
none |
GitHub is the one forge of V1 (§11); the other rows apply when the
operator can configure those forges. Image hosts: for GitHub
avatars.githubusercontent.com (avatars) and
repository-images.githubusercontent.com (social previews); for the
others the forge's own host. An image URL on any other host (a Gravatar
fallback, a CDN the hub does not know) is no image. The API reads use
the grant §11.4 picks for the repository — the instance token only for a
project in the operator's own namespaces; without a grant the hub reads
nothing from the forge and has no image (§11.4, no_grant). The GraphQL read is a POST that
only queries; the hub still never writes to the forge.
What the hub accepts, whatever the forge said:
- PNG, JPEG or WebP, recognised by their magic bytes (
89 50 4E 47 0D 0A 1A 0A;FF D8 FF;RIFF…WEBP), never by the upstreamContent-Type. SVG, GIF, HTML and everything else is no image. The hub does not decode or re-encode the image. - At most 1 MiB, under the transport rule of §11.1.
The answer is the image bytes with Content-Type from the sniffed type
(image/png, image/jpeg, image/webp), X-Content-Type-Options:
nosniff, Content-Security-Policy: default-src 'none'; sandbox,
Cross-Origin-Resource-Policy: same-origin, ETag (a digest of the
bytes) and Cache-Control: private, max-age=21600. A request with a
matching If-None-Match is 304 without a body. The hub keeps an image
for 6 hours and then reads it again; a failed refresh keeps the last
image, an answer that there is none drops it.
The forge image stays a cached read as written: the hub buffers it, keeps it for 6 hours and then reads it again, never streams it through.
Uploaded. PUT …/logo (…/banner) with the raw image as the body —
no JSON, no multipart — and Content-Type: image/png, image/jpeg or
image/webp. Human only: a cookie session with X-CSRF-Token (§2.1)
and project:admin; an agent credential, whatever its scopes, is
forbidden. The same acceptance rules as for the forge apply to the
body: the type is sniffed from the magic bytes, and the declared
Content-Type must name the sniffed type; SVG and every other type is
refused; at most 1 MiB (the body cap of §1.7 does not apply to this
call). The hub neither decodes nor re-encodes the image, so it checks no
pixel dimensions. It stores the bytes, the sniffed type and the ETag
(a digest of the bytes) in its database — a small table keyed by
project and kind (logo, banner), the bytes as a binary column
portable across the supported engines — and serves them with the
headers above. An upload replaces the previous one. The answer is 200
with {"logo": {available, source, etag}} (or banner), the shape of
the project view.
DELETE …/logo (…/banner) removes the upload; the forge's image applies
again (or none). 200 with the same shape; deleting where nothing was
uploaded is 200 as well (§1.5: DELETE is idempotent by itself).
Idempotency-Key as for every other state-changing call (§1.5): optional
for the cookie session; a repeat with the same key and the same body
replays, a different body is idempotency_mismatch.
Every change of what the project shows — an upload, a delete, a forge
refresh that changes the image while the forge is the source — emits
project_updated (§8.2) with fields.logo or fields.banner:
{available, source, etag}, never the image bytes.
Errors, GET: not_found when there is no image — no upload, and no
repository, nothing at the forge or an image the rules above refuse (the
dashboard then draws a monogram; that is the client's, not the hub's);
probe_unavailable when the forge did not answer and the hub holds no
earlier image. PUT: invalid with details.fields.body one of
unsupported_type (not PNG, JPEG or WebP by its bytes, or a
Content-Type that does not name the sniffed type) and too_large
(over 1 MiB); forbidden for an agent or without project:admin;
csrf_mismatch.
11.3 The Keep the Why export#
GET /projects/{owner}/{slug}/keep-the-why
GET /projects/{owner}/{slug}/keep-the-why?max_age=0
GET /projects/{owner}/{slug}/keep-the-why?member=hub
Scope project:read. The project's published Keep the Why dashboard
state (state.json), read-only: the hub reads what the project already
publishes and never reads or writes context/ (context/scope.md: Keep
the Why stays external). It holds the export in memory only, never in
its database, and keeps no copy of the entries beyond the cache below.
One exception, and only this one: the stored public view of a project
whose repository the hub last found public (§16.9, Why) holds the
summary counts, when the hub read them (observed_at), each family member's
name, path and dashboard link, and the attention entries' family member, titles, statuses, topics and links,
never anything else of an entry.
Where the URL comes from — the repository only. The dashboard-state:
line inside the keep-the-why:config block of the repository's root
.keep-the-why on the default branch, read through the forge's contents
API (GET /repos/{owner}/{repo}/contents/.keep-the-why on GitHub) with
the grant §11.4 picks for the repository (the instance token only for a
project in the operator's own namespaces), at most 64 KiB; without a
grant nothing is read (§11.4, no_grant). There is no project setting that
names or overrides the URL: the repository is the single source of truth,
as in Keep the Why itself. No repository, no root .keep-the-why, or no
dashboard-state: line in it: ktw_not_configured. A state URL must be
https, at most 2048 characters, name a host (no address literal), carry
no user info and no port other than 443, else that member's
ktw_unavailable with reason state_url_invalid.
The family. A .keep-the-why may carry a keep-the-why:children
block, one child per line:
<!-- keep-the-why:children -->
- hub: hub — the server: API, persistence, events, …
- cli: cli — the agent-side command `maity` …
<!-- /keep-the-why:children -->
Each line is - <name>: <location> — <scope>. The hub reads the whole
family from the root down and treats every value as untrusted data (the
skill's own rule for path-form family locations):
- Only a directory location is followed: a relative path inside the
repository, resolved against the directory of the
.keep-the-whythat lists it — segments of letters, digits,.,_,-, joined by/; never empty, never.or..as a segment, never absolute, never a backslash, never a URL. A URL location (a child in another repository) is a name, not a read: it is not followed and appears as a member with reasonlocation_not_followed, like a location that breaks the rule above. Because a location never climbs, every child lies below its parent and the family cannot loop; a path listed twice is read once. - For each child the hub reads
<path>/.keep-the-whythrough the same contents API (at most 64 KiB) and that file's owndashboard-state:line, then itsstate.jsonunder the same rules as the root; its ownchildrenblock is followed the same way. - Caps: depth at most 4 below the root, at most 32 members including the
root. A child past either cap is not read; the answer carries
family_truncated: true. - A member that fails fails alone: it is listed with its
error(and its last goodsummarywhen there is one), the others are served. Only a failure of the root.keep-the-whyread fails the whole export.
A member is {path, id, name, state_url, dashboard_url, observed_at,
summary, error}: path is its directory inside the repository (""
for the root); id is the id: line of its config block (null when
absent; cut at 200 characters); name is the <name> its parent's block
gives it (null for the root). Members are listed depth-first in the
order of the blocks, the root first.
The read. The transport rule of §11.1 for every read, each
state.json at most 1 MiB, Accept: application/json; at most 4 reads
of one project's family in flight at once. The .keep-the-why files and
the exports are cached together for max_age seconds (default 600). A
max_age below 30 is taken as 30: a fresh read of the whole family
happens at most once per project every 30 seconds, and a closer request
answers from the cache with fresh: false.
A failed read keeps the member's last good answer until the hub restarts.
What the hub relies on, per export. Unknown keys are ignored, at
every level; bodies, authors, project (the hub takes the family
from the .keep-the-why files, not from the export), each entry's git,
refs, see and the rest are never read.
| Key | Used as | Missing or wrong |
|---|---|---|
state-json |
the export's version: an integer, or a string whose part before the first . is one; the hub knows major 2 |
that member's ktw_unavailable, reason state_json_version, details.version |
generated |
summary.generated when it is an RFC 3339 time |
null |
entries |
an array of objects | that member's ktw_unavailable, reason shape |
entries[].id |
a string; the entry's identity | the entry is skipped and counted in skipped |
entries[].uuid |
a string; preferred for the link | the link uses id |
entries[].title |
a string | id |
entries[].type |
a string or an array of strings | [] |
entries[].status |
active, open, needs-review, pending-confirmation, superseded |
counted as other |
entries[].file, entries[].line |
the topic file, and the order within it | null |
topics[].file, topics[].title |
the topic title of an entry's file | topic: null |
findings.errors, findings.warnings |
the linter's counts, integers ≥ 0 | null |
Untrusted strings. Everything from a .keep-the-why and from an
export is data from outside the hub: control characters are removed,
id is cut at 300 characters, uuid at 64, title, file, topic
and a member's name at 200, each type at 40 and the list at 8, a cut
string ends in …. The hub answers JSON only and the dashboard renders
these strings as text (web/context/rendering.md), never as HTML. The
hub takes no URL from an export: a member's dashboard_url is its state
URL without the last path segment, query and fragment, and an entry's
link is that member's dashboard_url + #entry/ + the percent-encoded
uuid (else id) — the published dashboard's own entry anchor.
{
"observed_at": "2026-10-22T11:40:00Z",
"fresh": true,
"summary": { "members": 4, "failed": 0, "entries": 57,
"status": { "active": 55, "open": 2, "needs-review": 0, "pending-confirmation": 0,
"superseded": 0, "other": 0 },
"attention": 2, "findings": { "errors": 0, "warnings": 0 } },
"members": [
{ "path": "", "id": "oliver-zehentleitner---maity", "name": null,
"state_url": "https://maity.dev/keep-the-why-dashboard/state.json",
"dashboard_url": "https://maity.dev/keep-the-why-dashboard/",
"observed_at": "2026-10-22T11:40:00Z",
"summary": { "generated": "2026-10-22T11:33:15Z", "state_json": 2, "entries": 20,
"status": { "active": 19, "open": 1, "needs-review": 0, "pending-confirmation": 0,
"superseded": 0, "other": 0 },
"attention": 1, "findings": { "errors": 0, "warnings": 0 } },
"error": null },
{ "path": "hub", "id": "oliver-zehentleitner---maity---hub", "name": "hub",
"state_url": "https://maity.dev/keep-the-why-dashboard/hub/state.json",
"dashboard_url": "https://maity.dev/keep-the-why-dashboard/hub/",
"observed_at": "2026-10-22T11:40:01Z", "summary": { "…": "…" }, "error": null }
],
"attention": [
{ "member": "", "id": "hub.md#…", "uuid": "…", "title": "…", "type": ["question"],
"status": "open", "file": "hub.md", "topic": "Hub",
"link": "https://maity.dev/keep-the-why-dashboard/#entry/…" }
],
"family_truncated": false,
"truncated": false,
"skipped": 0,
"error": null
}
members and summary (per member and the aggregate) are the objects
the project view carries (§6.1). A member's summary.entries counts the
entries read, summary.attention those with status open,
needs-review or pending-confirmation; the aggregate adds up the
members whose summary is not null (a failed member's last good read
included), findings counts are summed where a member has them,
members counts the members listed and failed those with an error.
attention lists the entries that need attention with the member
(path) they belong to — needs-review first, then
pending-confirmation, then open, each by member order, file and
line — at most 200 in all, then truncated: true. skipped counts
skipped entries over all members.
?member=<path> narrows the answer to one member: members holds that
one, summary and attention are its own (?member= with an empty
value is the root). A path that is not a member of the family is
not_found.
Errors: ktw_not_configured; ktw_unavailable when the root
.keep-the-why could not be read (details.reason
forge_unavailable), with details.last — the last good answer in the
shape above — when there is one. A member's own failure is never an
error of the call: it is that member's error, {code:
"ktw_unavailable", reason} with reason one of forge_unavailable
(its .keep-the-why read failed), config_missing (no .keep-the-why
at the path), no_state_url (no dashboard-state: line),
location_not_followed, state_url_invalid, address_refused,
redirect_refused, unavailable (the host did not answer: connection
or TLS refused), timeout, http_status (with status),
too_large, not_json, state_json_version (with version), shape.
11.4 Forge access per owner#
The maintainer's decision of 2026-10-28: a project's forge reads use a grant from the repository's owner, not only the operator's one token. One instance token cannot read the private repositories of the people who host projects on an instance, and a token from the operator's own account can read the operator's private repositories, which no other project should see through the probe. So the hub reads a project's repository with the first of these that covers it (covers is defined below):
- The namespace owner's GitHub App installation (below). The project's namespace owner (§6.25) has linked an installation of the instance's GitHub App, and the installation includes the repository.
- The namespace owner's own token (below).
- The instance token (
MAITY_FORGE_GITHUB_TOKEN, §11), only for a project in one of the operator's own namespaces (below), and only for a repository the forge reports as public. The hub checksprivateon the repository with the instance token before the first read and on every refresh of the repository (at most every 10 minutes); a repository that is private, or whose visibility the hub cannot read, is never read with the instance token.
Only the namespace owner's grants count: a grant of another member,
another owner of the project included, is never used for this
project. An organisation (§6.26) holds no grant of its own: for a
project of an organisation, "the namespace owner's grants" in this
section are those of the organisation owner who confirmed the
repository (below), and nobody else's.
The owner confirms the repository. A grant of the owner (sources 1
and 2) is used for a project only while the project's repository.url
is one the namespace owner confirmed for it: repository carries
owner_confirmed (boolean). Only POST
/projects/{owner}/{slug}/repository/confirm confirms: the namespace
owner, as a human acting directly (no proxy, no agent), with the step-up
of §2.8.4, with {"url": "<the repository URL the owner saw>"}; it
confirms only when that is the URL the project carries at that moment,
otherwise it answers 409 conflict (details.reason:
repository_changed, details.url the current one) and
owner_confirmed stays false, so a co-admin's change between what the
owner saw and the click is never confirmed with it. Every
change of the URL — a PATCH by anyone, the namespace owner included;
no other call sets the URL (the card import of §6.14 reads a forge
issue and never changes the project's repository) — sets it to false,
and until the owner confirms
the repository is read with the instance token alone, so only in the
operator's own namespaces and only while it
is public. So no co-admin can
point a project at another of the owner's private repositories and have
it read with the owner's access. A transfer (§6.25) resets
owner_confirmed to false: the new namespace owner's grants apply only
once they confirm the repository, and the transfer's consent request
(§6.25) names the repository URL, so the receiver sees what they would
be asked to grant. The old owner's grants stop with the transfer.
repository also carries confirmed_by: {id, handle} of the human
whose grants the confirmation put to use, or null while
owner_confirmed is false. For a project of a human it is the
namespace owner. For a project of an organisation any owner of the
organisation confirms, under the same rules, with their own grants:
the hub records them as confirmed_by and uses their grants alone. A
later confirmation by another owner replaces them. When the human
named in confirmed_by stops being an owner of the organisation —
demoted, removed, leaving, their account deleted — owner_confirmed is
reset to false and confirmed_by to null, as on a transfer, and
their grants stop at once; another owner confirms again.
When a source covers a repository. The hub decides it per source, on the first read and again on every refresh of the repository (at most every 10 minutes, and at once after a refusal):
- an installation covers it when the forge names, for the repository,
an installation of the App (
GET /repos/{owner}/{repo}/installationwith the App's key) that is one of the namespace owner's active links; any other installation of the App, the owner's own others included, is never tried; - the owner's token covers it when a read of the repository with that token answers it;
- the instance token covers it when the project is in one of the operator's own namespaces and the forge reports the repository public (above).
The operator's own namespaces. The maintainer's decision of 2026-11-02: projects connect their repository to the forge themselves, through the App or the owner's token; the instance token is the operator's and serves the operator's own projects only. The operator names them in the environment:
| Setting | Meaning |
|---|---|
MAITY_FORGE_INSTANCE_NAMESPACES |
comma separated namespace names (§6.25: a human's handle or an organisation's name, the {owner} of a project's address); spaces around a name are ignored. Unset or empty: the instance token serves no project |
A project is in one of them when its current namespace is one of the names, compared in lower case. A rename of a listed namespace (§6.24) takes its projects out until the operator lists the new name; a name given up stays its id's alias and is never another's (§6.24), so a listed name cannot pass to someone else. A rename and a transfer (§6.25) into or out of a listed namespace apply to the next read at once: the project's current namespace is part of what the choice of its source depends on, like the URL and the owner's grants, not kept for the 10 minutes above.
A listed name that is neither the handle nor an alias of any human or
organisation is held for the operator while it is listed: no human or
organisation can take it through the hub's API — registration
(§2.7.4), POST /orgs and every rename (§6.24) answer invalid,
details.fields.handle: "reserved", as for a reserved first segment —
so a typing error or an organisation not yet created cannot later be
registered by someone else and read with the instance token.
maity-hub owner create is not held: it runs on the server with the
hub's own configuration and database access, so whoever runs it is the
operator, who also sets the list; no request from outside reaches it.
An alias needs no hold: it is never
another's (§6.24), and its own id may take it back by a rename. The hub
logs a warning at start for each held name, and for a listed name that
is only an alias, whose namespace is not served until its current
handle is listed. An agent profile's handle is not an address and is not
checked against the list. Removing a name from the list frees it again.
For every other project the instance token is used for no read at all:
not the probe, not the visibility of §16.9, not the first look at a new
project's repository, not an import (§6.14). Such a project is read only
through its namespace owner's App installation or token, after the owner
confirmed the repository; its visibility (§16.9) comes from that read,
and the bare number of merges (the one exception below) may come from
it as before. Without such a grant probe_via is null, every read
answers probe_not_configured with details.reason: no_grant (with a
grant the owner has not confirmed, unconfirmed), and the hub counts
the repository as one it cannot read: a new project's page starts
off (§16.9), and an existing page shows no forge data. The dashboard
tells the namespace owner to connect the App or a token, not that the
instance reads it.
A repository the forge answers with a redirect (renamed or moved) is not
followed: reads stop with details.reason: repository_moved until a
project admin sets the new URL, which then needs the owner's
confirmation again.
The project's repository (§6.1) says which source is used:
probe_via is app, owner_token, instance or null, and
probe_configured is true exactly when probe_via is not null.
When no source covers the repository, every read answers
probe_not_configured with details.reason: no_grant (nothing covers
it), private_without_grant (the project is in one of the operator's
own namespaces, only the instance token covers it, and the repository
is private), installation_suspended or
installation_removed (the owner's installation covered it and no
longer does), token_rejected (the owner's token was refused by the
forge), unconfirmed (a grant of the owner would cover it, but the
owner has not confirmed this repository), repository_moved. The reason is shown to project:admin only; other readers get
probe_not_configured with details: {}.
What a private repository's data may reach. What the probe reads
is project data: it reaches exactly the readers of the record it lands
on (a card's forge link, a report's observed, a release, a ticker
item, §17.3). A page without sign-in — the badge (§6.20), the public pages
(§16.9) and the landing page (§16.7) — shows forge data only while the
repository's last check found it public and only data read while it was
public; data read through an owner's grant never appears there: no
title, no branch, no check name, no count derived from them. One
exception: the bare number of merged pull requests (§16.9) may come from
a read through the GitHub App or an owner's token, as long as that read
found the repository public; nothing else from such a read appears
there, no title, no name, no link.
The GitHub App. The operator registers one GitHub App for the instance and configures it in the environment:
| Setting | Meaning |
|---|---|
MAITY_FORGE_GITHUB_APP_ID |
the App's id |
MAITY_FORGE_GITHUB_APP_SLUG |
the App's URL name, for the installation link |
MAITY_FORGE_GITHUB_APP_PRIVATE_KEY |
the App's private key (PEM), from a Secret, never logged, never in an answer |
MAITY_FORGE_GITHUB_APP_CLIENT_ID, MAITY_FORGE_GITHUB_APP_CLIENT_SECRET |
the App's OAuth client, used once per link to confirm who installed it (below); the secret from a Secret |
All five or none: a partial set stops the hub at startup. The App asks for read access only — metadata, contents, pull requests, checks, commit statuses, deployments, issues — and for no write permission and no account permission; the hub never writes to the forge (§11). The App's own webhook is not used in v1; the project webhook (§17.4) stays as it is.
Linking an installation. A human links an installation to their account, not to a project; it then serves every project they own whose repository it includes.
POST /me/forge/github/link(human) answers{url}: the App's installation page with astateof 256 random bits that the hub stores only as a hash, bound to the caller's session, valid 15 minutes and usable once. Without the App configured it is409 forge_app_missing, andGET /me/forgesaysapp_configured: false. The App is set up to ask for the user's authorisation during installation, so the forge sends the browser back with bothinstallation_idand an OAuthcode.GET /forge/github/setup?installation_id=…&code=…&state=…(cookie session, the same human whose session thestatenames) completes it. The hub exchangescodefor a user token, reads that user (GET /user) and the installation (with the App's key), and links the installation only when its account is that user:account.typeisUserandaccount.idis the user's id. Listing the installations the user can access is not enough, since a member of an organisation with access to one repository would pass it and the hub would then read every repository of the installation. Installations on an organisation are not linked in v1 (403 forbidden,details.reason: organisation_installation), also now that Maity has organisations of its own (§6.26; "Follow-up" below): a project of a Maity organisation is read with the grants of the owner who confirmed it (above). The user token is used for this one check and discarded, never stored. An installation of another account is refused (403 forbidden,details.reason: installation_not_yours), so nobody links an installation by guessing or copying its id. A missing, expired, used or foreignstateis refused withdetails.reason: link_state. Since the browser arrives here by the forge's redirect, every outcome is a303to the dashboard's forge settings (<MAITY_PUBLIC_URL>/settings/forge, §16.11): on success without a query, otherwise with?error=<reason>, the reason one oflink_state,installation_not_yours,organisation_installation,installation_linked(below),forge_app_missing, orforge_unavailablewhen the forge did not answer; never with a forge token, code or state in the URL. To a client that sendsAccept: application/jsonthe same reasons answer as JSON:403 forbiddenfor the first three,409 conflictforinstallation_linked,409 forge_app_missing, and503 forge_unavailable.- An installation is linked to one human at a time. Linking one that is
linked to another human is refused (
409 conflict,details.reason: installation_linked); the other human unlinks it first, or the installation is removed on the forge. GET /me/forge(human) answersapp_configured(whether the instance has a GitHub App, above) and lists the caller's links:[{id, forge, installation_id, account: {login, type}, repository_selection: "all" | "selected", repositories_shown, status: "active" | "suspended" | "removed", linked_at, checked_at}]and the caller's own token (below) as{configured, login, status: "active" | "rejected", checked_at}; never a token.repositories_shownlists at most 100 full names the installation covers, read with an installation token when the list is requested.- Starting a link and storing a token need the step-up (§2.8.4); an unlink and a token's removal do not, since they only take access away.
DELETE /me/forge/github/links/{id}(human, the linking human) unlinks it. It does not uninstall the App on the forge; the dashboard says so and links to the forge's page.
Installation tokens are minted per installation with the App's key
when a read needs one, held in the hub's memory until 5 minutes before
they expire (the forge issues them for an hour), and never stored, logged
or put into an answer. A mint that the forge refuses because the
installation was suspended or removed sets the link's status and the
reason above; the hub retries a suspended one at most every 10 minutes
and a removed one never, until it is linked again.
The owner's own token. A human may store a token of their own as a second source, for a forge setup where an App cannot be installed.
PUT /me/forge/github/token{token}(human) checks the token with the forge (GET /user) and stores it encrypted withMAITY_SECRET_KEY(§17.4). Only a fine-grained token is taken; a classic token is refused (invalid,details.fields.token: "classic_token"): a classic token reaches a private repository only with thereposcope, which can write, and without it reads no more than a public repository shows anyone. The forge does not report a fine-grained token's permissions, so the hub cannot check them; the dashboard asks for read-only permissions on the chosen repositories and says that the hub never writes (§11), which holds whatever the token could do. Without that key it is409 secret_key_missing. It answers{configured: true, login}; the token is never returned.DELETE /me/forge/github/token(human) removes it.- A token the forge refuses later sets
status: "rejected"and the reasontoken_rejected; the hub does not try it again until it is replaced. - The token is a credential: masked in the request log, in no event, never in an answer.
Both sources are the human's own: their links and token are readable by
them alone, never by an instance admin, an agent or a runner. A change
writes an account record (§18.1) forge_access_changed {forge,
source: "app" | "owner_token", change: "linked" | "unlinked" |
"suspended" | "removed" | "token_set" | "token_removed" |
"token_rejected", login} of that human (login is the forge account the
installation or token is on, which the item's title names; never a token), which is a security item for
linked and token_set and no notification otherwise.
Rate limits. A source's reads count against that source's limit at
the forge (an installation, the owner's token, the instance token); the
hub keeps one budget per source and, when the forge says the budget is
spent, answers probe_unavailable for that source's projects until the
forge's reset time, with details.retry_at. Since one source serves all
of an owner's projects, a forced fresh read (max_age=0, §11) is made
at most once per 30 seconds per project and target; within that time
the cached observation is answered with fresh: false.
Follow-up: organisations. Maity's organisations (§6.26) and the
forge's are not linked here. An installation of the App on a forge
organisation stays refused (organisation_installation, above); a
project of a Maity organisation uses the grants of the owner who
confirmed it (above). Whether an installation on a forge organisation
may serve a Maity organisation's projects is open for its own
contract; until then nothing else in this section changes.