# Public activity pages

A public activity page shows human contributions to public GitHub and GitLab repositories. It includes pull requests, reviews, and issues from workspace members and outside contributors. A person’s detail shows counts by repository, a public work timeline, and weekly pull request, review, or issue counts. Work titles link to the provider. It excludes private repositories, account details, automation, practices, feedback, Slack, Outline, and AI content. Keep course workspaces private. Before publication, verify the activity repair described in the [activity history runbook](/admin/activity-history-repair.md).

## Settings in the UI

Three screens control public activity pages. Each change takes effect at once. Visitors see a change when they load the page again. A shared cache can delay that by up to a minute. A page that is already open in a browser keeps its contents until it loads again.

| Who                     | Where                                            | Control                                        |
| ----------------------- | ------------------------------------------------ | ---------------------------------------------- |
| Instance administrator  | **Instance settings**, **Public activity pages** | **Allow public activity pages**                |
| Workspace administrator | **Workspace settings**, **Public activity page** | **Publish the page**, **Allow search engines** |
| Workspace administrator | **Workspace activity**, a person's level         | **Hide from activity**                         |

### Instance administrators

**Allow public activity pages** is the instance switch. While it is off, no workspace can publish a page, and every public page stops. A change asks for a recent sign-in. The switch shows the stored choice, not the configured default.

### Workspace administrators

**Publish the page** asks first. The confirmation states what becomes public:

* everyone who contributed to the public repositories, members and outside contributors, by name, picture and profile link
* the pull requests (merge requests on GitLab) each person opened and reviewed, the issues they opened, their public work timeline, and weekly counts of their activity

It also states that the page shows nothing from private repositories, practices, feedback, AI content, Slack, Outline or automation accounts. Turning the page off needs no confirmation.

**Publish the page** shows the saved choice of the workspace. The saved choice alone does not make the page public. For the other conditions, see [Workspace settings](#workspace-settings). When the saved choice is on and public availability is confirmed, the section shows the address of the workspace. Signed-in members open their workspace home at that address. To preview the page, open the address in a private window. If the page is on but public availability is not confirmed, the section shows no address and says that availability is not confirmed.

**Allow search engines** is off by default, and it exists only while the page is on. Turning the page off sets it back to off, so publishing again starts with search engines off. Off, the page sets `<meta name="robots" content="noindex">`, and the API response carries `X-Robots-Tag: noindex`. The webapp sets the meta tag with JavaScript, so a crawler that does not run JavaScript does not see it. This is a request to search engines, not access control.

**Hidden people** shows how many people the page leaves out and does not count. It includes people who hid themselves and people an administrator hid. It never shows who.

To leave out a person who has no membership, and so no setting of their own, open the person on **Workspace activity** and select **Hide from activity**. The action is not offered for a member. Hide a member under **Members**. The person then leaves the public page and Workspace activity. Right after the action, the message **Undo** restores the person. Later, **Workspace settings** lists the people without a membership whom an administrator hid, under **Hidden by an admin**. Select **Show again** beside a person to restore them.

### Members

The first time a signed-in person opens a workspace that publishes a page, a step says that the page is public and what it shows of them. **Show me** and **Hide me** have the same weight. Either answer records that the person saw the step for this workspace. **Decide later** and Escape leave the step for that visit without an answer, so a person who has not answered sees it again. A save that fails or takes more than 15 seconds shows an error, and the person can answer again or decide later. The AI setup of the workspace comes first, and the step waits until it ends. A person who is not a member sees the same step on the public page after signing in.

**User settings** has the same choice as **Show me on public activity pages**. It applies to every public page, in every workspace, at once. After either choice, other people see the change when they load the page again, at most about a minute later.

## Instance setting

**Allow public activity pages** defaults to off. Set `HEPHAESTUS_PUBLIC_ACTIVITY_ENABLED=true` to supply an instance default. An instance administrator can read and replace this default with `GET` and `PUT /admin/settings/public-activity`. The PUT body is `{"allowed": true}` or `{"allowed": false}`. The stored choice takes precedence over the configured default and is audited. Turning the setting off stops all public activity pages on the next request.

## Workspace settings

A workspace administrator can read `GET /workspaces/{slug}/public-activity` and change `PATCH /workspaces/{slug}/public-activity`. The PATCH body is `{"publicActivityEnabled": true, "allowSearchEngines": false}`. Both fields are required. Changes are audited. The page is available only when the instance allows it and the active workspace enables it. The old whole-workspace public flag no longer grants anonymous access.

**Allow search engines** defaults to off. The JSON response then carries `X-Robots-Tag: noindex`. The DTO also returns `allowSearchEngines: false` for a page client to set `<meta name="robots" content="noindex">` on its HTML page. The header protects the JSON response only; it does not protect the webapp HTML. The API does not set a robots instruction on HTML pages; the page client must supply it. Neither instruction is access control. See [Google's robots header contract](https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag).

## Personal choice and objections

Each account defaults to visible on public activity pages. A signed-in person can read and change `GET` and `PUT /user/public-activity` with `{"visible": false}` or `{"visible": true}`. The choice applies to all linked provider identities in every workspace. A minimal provider-id and subject marker retains an objection after unlinking or account deletion. Showing activity again clears markers for identities currently linked to the account, not identities it no longer owns. This marker affects publication only; it does not suppress internal processing. Hidden people do not contribute to public totals. An administrator can read only the count through `GET /workspaces/{slug}/activity/public-hidden-count`. This endpoint does not identify the hidden people.

A workspace administrator can list the people without a membership whom an administrator hid with `GET /workspaces/{slug}/activity/hidden-contributors`. It lists administrator decisions only, not account objections.

For an objection from a person without an account, the workspace administrator can use `PATCH /workspaces/{slug}/activity/people/{userId}/public-visibility?hidden=true`. Use `hidden=false` to restore visibility. The action reuses workspace activity hiding and therefore also hides that contributor from member activity. It does not erase source work, block provider synchronization, or grant workspace access. Restoring workspace visibility never overrides an account-wide opt-out. For an outside contributor, the audit entry records the workspace and change, not the contributor's identity. For a member, this action uses the existing membership visibility operation and its workspace-role audit entry. An objection with broader scope belongs in the existing person-data request process.

Workspace administrators handle this action because their workspace publishes the activity. Instance administrators can use the existing audited workspace elevation when they handle the privacy contact. Do not use the permanent erasure suppression mechanism for a reversible publication objection.

## Onboarding contract

A signed-in visitor can read `GET /user/public-activity/workspaces/{slug}/onboarding`. The response gives `seen` and the account-wide `visible` choice. `PUT` with `{"visible": true}` or `{"visible": false}` applies the choice and marks this workspace as seen in one transaction. The public-workspace visitor needs no workspace membership. The account always comes from the authenticated session. A page client can use this state for a one-time Show me / Hide me step.

## Anonymous responses

Three routes publish workspace activity without sign-in, through `GET` and `HEAD`:

| Route                                                    | Published data                                                            |
| -------------------------------------------------------- | ------------------------------------------------------------------------- |
| `/public/workspaces/{slug}/activity`                     | People and repository facets                                              |
| `/public/workspaces/{slug}/activity/people/{login}`      | One person's profile identity, counts, weekly breakdown, and repositories |
| `/public/workspaces/{slug}/activity/people/{login}/work` | That person's public work timeline, without authors or participants       |

Each accepts activity range presets, custom dates, and `repo`, which names public repositories to count and repeats for more than one. The work route also accepts repeated `kinds`, `size` from 1 to 100 (default 30), and the preceding page's `nextCursor` as `cursor`. The supported kinds are pull requests opened or merged, reviews approved, requesting changes or commented, and issues opened. The person response lists these as `supportedKinds`. Comments on code or in conversations, and close events, are not published. An unknown repository is a not-found error. The response names the provider (`providerType`) so a client can use its words and icons. Each query requires stored public visibility and a recent successful repository metadata read for this workspace. A missing confirmation, an expired confirmation, or a known unavailable repository excludes the repository, its contributions, and its coverage from the public response. A sync that reports repository access loss (404 or authorization failure) marks that workspace's monitor unavailable immediately. Installation repository removal and GitLab project removal or transfer out of scope stop publication by removing the workspace monitor. A GitLab project event whose metadata lookup reports access loss also marks the monitor unavailable. These actions do not change another workspace's access to the same repository. A successful sync can restore publication when it confirms that the repository is public and accessible again.

`HEPHAESTUS_PUBLIC_ACTIVITY_REPOSITORY_VISIBILITY_MAX_AGE` supplies `hephaestus.public-activity.repository-visibility-max-age` and defaults to `48h`. The value must be a positive duration. This is twice the default daily `hephaestus.sync.cron` interval (03:00 server time). Use twice your configured metadata sync interval when you change the cadence. The limit is enforced during each public query, without a cleanup job. If sync is disabled, credentials stop working, or metadata fetches keep failing, publication stops no later than this limit after the last successful repository metadata read. A failed metadata fetch does not renew the confirmation. The separate visibility confirmation does not advance when only issues, pull requests, or other resources sync. Repositories without a successful metadata confirmation after this upgrade are not published. Run a repository sync before enabling a page.

GitLab project create, rename, transfer and update events refresh stored visibility. A system hook supplies `project_update`; normal project and group webhooks do not supply visibility-change events. Push events and metadata sync paths also refresh stored visibility. Without a delivered visibility event, a private change is detected by the next successful metadata sync, or the repository is excluded when its confirmation expires, whichever happens first. The existing 60-second anonymous cache lifetime applies after exclusion as it does after a publication setting change. See [GitHub installation repository events](https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation_repositories), [GitLab system hooks](https://docs.gitlab.com/administration/system_hooks/) and [GitLab webhook events](https://docs.gitlab.com/user/project/integrations/webhook_events/#project-events). Unknown, inactive, and disabled pages return the same not-found problem. Other workspace endpoints require sign-in and workspace access.

Successful anonymous responses carry `Cache-Control: public, max-age=60`. Signed-in responses carry `no-store`. The response varies by Cookie and Authorization. The existing shared rate limiter defaults to 60 anonymous requests per minute per client IP across workspace slugs. Set `HEPHAESTUS_RATE_LIMIT_PUBLIC_ACTIVITY_CAPACITY` and `HEPHAESTUS_RATE_LIMIT_PUBLIC_ACTIVITY_PERIOD` to adjust this bucket. They supply `hephaestus.auth.rate-limit.public-activity.capacity` and `.period`. Signed-in requests do not consume this anonymous IP budget. It refuses requests if its rate-limit backend is unavailable. Proxy trust comes from the servlet container, not a second parser for forwarded headers.

Publication changes apply to new server requests. A browser or shared cache can retain an earlier anonymous response for up to 60 seconds. The server cannot recall a response already delivered. See [Spring Security's cache header contract](https://docs.spring.io/spring-security/reference/servlet/exploits/headers.html).
