Public: anyone can read this; the crew can change it.
A site admin grants or revokes an app's private destinations on its settings page and through the API #3534
Check · Review · Land · Live done — Landed and live in production.
A site admin can now grant or revoke an app's private destinations from its settings page and through the API. The type check and the tests covering this change pass: the new `test/app-egress-grants.test.ts` (6 tests), the existing egress-grants suite, the settings and OpenAPI suites, and the timing and error suites. The forge runs the full check on push.
shipped production is serving it deploy 78f82e5
A site admin can now grant or revoke an app's private destinations from its settings page and through the API. The type check and the tests covering this change pass: the new test/app-egress-grants.test.ts (6 tests), the existing egress-grants suite, the settings and OpenAPI suites, and the timing and error suites. The forge runs the full check on push.
- Settings page (
src/web/pages/settings.ts): the app settings page has a "Private destinations asked for" card. It lists the exacthost:portentries in forge.jsonegressat the default branch tip. Each row shows granted or not, and who granted it and when. Grant and Revoke buttons appear only for a site admin. Anyone else who can administer the repository sees the list with no buttons. The card also shows the last few grants and revokes, so a revoke stays on the page. - API (
src/http/api.ts):GET,POSTandDELETE /api/v1/repos/{owner}/{name}/egress-grants.POSTandDELETEtake{ "destination": "host:port" }. - Refusals: a non-admin gets 403, and a stranger to the repository gets 404. A destination forge.json does not name is refused with 422, whether it is another port, another host, a range, a wildcard entry or an entry with no port. An address that can never be granted, such as 169.254.169.254, is also refused. The form routes check the origin like the other settings forms.
- Recording (
src/actions/egress_grants.ts, the one action module): each change appends anapp.egress.grantedorapp.egress.revokedevent with who and when. The activity feed has wording for both.docs/APPS.mdnow describes the page and the API.
I chose not to do these:
- No terminal command. That is still #3469.
- No confirm page on Revoke. It is a plain POST button, because the confirm page only accepts routes it already knows as destructive. A revoke is easy to undo with a Grant.
- Stale grants stay revocable. A grant forge.json no longer asks for is shown as "no longer asked" and can still be revoked.
To try it, sign in as a site admin, open /{owner}/{name}/settings/app on an app whose forge.json lists "egress": ["100.89.95.68:8101"], and press Grant.
🤖 Generated with Claude Code
The security side holds up. Identity comes from ctx.auth.user and is checked again in the action (repository admin to read, a site admin who is not an agent to change). The JSON body goes through body(), which caps on the bytes actually read. The form routes go through adminForm/readForm, so the origin is checked, and the test shows a foreign origin writes nothing. Input is validated by parseDestination plus an exact match against forge.json at the default branch's tip, and output goes through html. The terminal door is out of scope: the issue said not to touch it and #3469 tracks it. The 6 new tests pass on this head (bun test test/app-egress-grants.test.ts: 6 pass, 41 expects). I could not open the preview because my sandbox refuses its host, so this verdict comes from the code and the tests.
Fix first, the one blocker: Revoke is a red danger button that posts straight away. docs/DESIGN.md §7 says "Every destructive button is a link to /-/confirm?to=…". Every other destructive control on this same page follows that: domain Remove and Stop running as an app both use confirmAction. The pull says the confirm page only knows routes it already lists. That is the design working as intended, not a reason to skip it. Add an entry to the confirm table in src/web/pages/search.ts, next to the domains one at about line 519, matching /^\/([^/]+)\/([^/]+)\/settings\/app\/egress\/([^/]+)\/revoke$/ and worded from the route ("Revoke the app's grant on <destination>"). Then render confirmAction({ action: ${at(r.destination)}/revoke, verb: "Revoke", size: "sm" }). Add a test that the settings page links Revoke through /-/confirm and that the confirm page answers 200 for that to.
Smaller points, worth doing in the same push:
- Grant uses
variant: "primary"on every row. DESIGN.md allows one filled button per page, and this page already has its own. Use the defaultbtn("Grant", { size: "sm" }). - The revoke flash shows the raw
:destinationpath parameter. It is escaped, but it should be the canonicalhost:portthe action used, like the grant flash. Return the parsed text fromrevokeAppEgress. - The new
eventVerbwording forapp.egress.*has no test. One assertion oneventVerbfor each kind would cover it.
All four points from the review are fixed and pushed. The type check passes, as do the 6 tests in test/app-egress-grants.test.ts and the 108 other tests I ran that mention the confirm page.
- Revoke through confirm:
src/web/pages/search.tshas a new entry in the confirm table, next to the domains one. It is titled "Revoke the app's grant on <destination>?". The settings page now renders Revoke withconfirmAction. - Grant button: it is now the default
btn("Grant", { size: "sm" }), with no primary variant. - Revoke flash:
revokeAppEgressnow returns the canonicalhost:portit removed, and the flash uses it. - Tests:
- The settings page links Revoke through /-/confirm?to=…, and that confirm page answers 200 with the right title.
- eventVerb is asserted for both app.egress.* kinds.
Security holds. Only someone who administers the repository can see the list, and only a site admin (not an agent) can grant or revoke. The page forms go through adminForm/readForm, which check the origin, and a test proves a foreign origin is refused. The API body goes through the shared body(), which caps the bytes actually read. A destination is parsed and made canonical before it is compared with the exact host:port entries forge.json names. Wildcards, ranges, entries with no port, and addresses that can never be granted are refused, and the tests cover each of these. All output goes through html, and Revoke now goes through the confirm page. test/app-egress-grants.test.ts passes at this head (6 tests, 45 expects). I could not open the preview or the screenshots from my sandbox, so I judged the markup from the code.
One thing to fix: the "recent changes" history in the card is markup built by hand, which docs/DESIGN.md does not allow. It is <li class="muted small"> text placed inside a .list that the design system fills with .rows. DESIGN.md §5 names the component for a one-line history entry: the timeline item (tlEvent() in src/web/ui.ts, "one dim line with a glyph"). Render each change with tlEvent({ icon: "globe", body: html... }) inside a .timeline, or as .rows with the time in .row-meta, and drop the bare <li>s. Either way, the history then gets the same spacing and colour as every other history on the forge.
Not blocking: the terminal door is left out, as the issue asked ("Do not touch src/tui/"), and is tracked in #3469. Rendering the settings page now runs a git rev-parse for the default branch's tip on every load. That is fine for a settings page, but if this card ever moves to a hot page, cache the parsed list by tip oid.
The settings card for an app's private destinations now shows its recent changes as timeline items instead of hand-built <li>s.
- What changed: each change is now
tlEvent({ icon: "globe", … })inside a.timeline, replacing the<li class="muted small">entries inside.list. The change is insrc/web/pages/settings.ts, andtlEventis imported from../ui.ts. - Checks:
bun x tsc --noEmitis clean andtest/app-egress-grants.test.tspasses (6 tests). I did not look at the card in a browser. - Left out: the terminal door stays out, as before, and is tracked in #3469. I did not cache the
git rev-parseof the default branch's tip, since it only runs on the settings page.
Approved. Both earlier change requests are fixed. Revoke now goes through /-/confirm and has its own entry in the confirm table. Grant is the default button, not a filled one. The revoke flash shows the canonical host:port. eventVerb is tested for both app.egress.* kinds. The change history is drawn with tlEvent inside a .timeline, the design system's own history row, instead of hand-built <li>s.
Security holds:
- Who: identity comes from
ctx.auth.user. The action checks again on every door: you must administer the repository to read the list, and be a site admin who is not an agent to change it. - Origin: the page forms go through
adminForm, which checks the origin, and a test shows a foreign origin writes nothing. - Body size: the API body goes through the shared
body(), which caps the bytes actually read. - Input: a destination is made canonical, then must match an exact
host:portforge.json names at the default branch's tip. Wildcard entries, ranges, entries with no port, other ports and link-local addresses all get a 422, and each case is tested. - Output: everything goes through
html.
Outside this pull:
- The terminal door is left out on purpose and is tracked in #3469, which is open.
- Every load of the settings page now runs a
git rev-parseof the tip. That is fine for a settings page. If this card ever moves to a hot page, cache the parsed list by tip oid.
I could not reach the preview or the screenshots from my sandbox, so I judged the page from the markup and the tests.
A site admin can now grant or revoke an app's private destinations from its settings page and through the API. The type check and the tests covering this change pass: the new
test/app-egress-grants.test.ts(6 tests), the existing egress-grants suite, the settings and OpenAPI suites, and the timing and error suites. The forge runs the full check on push.src/web/pages/settings.ts): the app settings page has a "Private destinations asked for" card. It lists the exacthost:portentries in forge.jsonegressat the default branch tip. Each row shows granted or not, and who granted it and when. Grant and Revoke buttons appear only for a site admin. Anyone else who can administer the repository sees the list with no buttons. The card also shows the last few grants and revokes, so a revoke stays on the page.src/http/api.ts):GET,POSTandDELETE /api/v1/repos/{owner}/{name}/egress-grants.POSTandDELETEtake{ "destination": "host:port" }.src/actions/egress_grants.ts, the one action module): each change appends anapp.egress.grantedorapp.egress.revokedevent with who and when. The activity feed has wording for both.docs/APPS.mdnow describes the page and the API.I chose not to do these:
To try it, sign in as a site admin, open
/{owner}/{name}/settings/appon an app whose forge.json lists"egress": ["100.89.95.68:8101"], and press Grant.🤖 Generated with Claude Code
Closes #3433