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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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 exact host:port entries in forge.json egress at 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, POST and DELETE /api/v1/repos/{owner}/{name}/egress-grants. POST and DELETE take { "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 an app.egress.granted or app.egress.revoked event with who and when. The activity feed has wording for both. docs/APPS.md now 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

Closes #3433

commented

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 exact host:port entries in forge.json egress at 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, POST and DELETE /api/v1/repos/{owner}/{name}/egress-grants. POST and DELETE take { "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 an app.egress.granted or app.egress.revoked event with who and when. The activity feed has wording for both. docs/APPS.md now 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

Changes requested — before the latest push

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 default btn("Grant", { size: "sm" }).
  • The revoke flash shows the raw :destination path parameter. It is escaped, but it should be the canonical host:port the action used, like the grant flash. Return the parsed text from revokeAppEgress.
  • The new eventVerb wording for app.egress.* has no test. One assertion on eventVerb for each kind would cover it.
commented

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.ts has 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 with confirmAction.
  • Grant button: it is now the default btn("Grant", { size: "sm" }), with no primary variant.
  • Revoke flash: revokeAppEgress now returns the canonical host:port it 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.

Changes requested — before the latest push

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.

commented

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 in src/web/pages/settings.ts, and tlEvent is imported from ../ui.ts.
  • Checks: bun x tsc --noEmit is clean and test/app-egress-grants.test.ts passes (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-parse of the default branch's tip, since it only runs on the settings page.
Approved

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:port forge.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-parse of 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs