You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The roadmap for the handler conversion, recovered from a local planning file that was never
committed. It is here so the stack has a link.
Decision
Deepen the route declaration seam before converting the remaining ~160 handlers.
#6941 gave routes three shapes and a gateway.Declaration recording what a
route claims. #7030 made mounting and declaring the same act, so a declaration
now knows its own address, permission and API-key policy, and the route-table tests are invariants
over every route the router mounts rather than a hand-maintained ledger.
Stack
master
└─ #6941 / cloud#2509 ......... three shapes (open)
└─ #7030 .................... registration declares the whole claim (open)
└─ A-cloud ............... cloud adopts the same registration (spec: team#239)
└─ B .................. list-query contract moves into the List shape
└─ A′ ............... every route states a permission or why it needs none
└─ A′-cloud ...... the same for the /admin/api surface
└─ C… ......... per-resource conversion (separate planning pass)
The original plan ordered this A → A′ → B, with cloud following one step behind at each rung.
Reordered for two reasons: cloud/ does not compile against #7030 until A-cloud lands, which
taxes every change made in the workspace meanwhile; and A′ is a security review needing a human
answer per route, so it should not block a mechanical cleanup. B depends on A only — Accepts
is a RouteOption on the mount call, which #7030 delivered.
A-cloud — cloud adopts the same registration
Spec: shellhub-io/team#239.
The four shape constructors changed return type in #7030, so internal/billing/routes, internal/admin/routes and internal/cloud/routes no longer build. Mechanical: the same diff shape
as #7030 over 81 call sites, with Requires/NoAPIKey/Guard already built and tested. Cloud
registers no declarations today, so the audits do not yet see it.
Note the billing webhook registers on the router root, and the two admin groups are groups — all
three are covered by gateway.MountOn(router, target).
B — the list-query contract belongs to the List shape
Goal. Delete the 14 shellhub (and later 7 cloud) copies of normalize → unmarshal → validate →
log → 400, and the 171-line go/ast test that polices them.
Shape.gateway.Accepts(services.DeviceQuery) on the registration; prepare[T] runs the
contract before the handler is entered, where it already normalizes the paginator and sorter. The
per-resource FieldSet/FieldConstraints pairs move behind one named value per resource.
Carries these fixes. The List shape owns X-Total-Count, so the three endpoints answering it
with len(list) — the page size, not the collection size — are corrected as a side effect: access-policy.go:32, service-account.go:34, ssh-identity.go:48. invitation.go's FormatInt/Itoa split goes with them. The default sort columns picked in api-key.go:52 and install-key.go:57 move to the resource's contract, where they are policy rather than handler code.
Deletes.server/api/routes/list_validation_test.go, replaced by a check over Declarations():
a List route names a query contract.
A′ — every route states a permission claim, or why it needs none
A security review, not a mechanical diff. 21 mutating shellhub routes carry no permission today.
Most are legitimately self-service or pre-credential (auth, register, accept invite, leave
namespace, web reauth, setup) and want gateway.NoPermission("...") with a written reason.
One is not what the route table suggests: DeleteSSHIdentity registers with no permission while its
two siblings require SSHIdentityAdd. It is guarded, in the handler body, so the behaviour is
correct — the route table under-reports it, which is exactly the class of thing declaring the
permission at the registration is meant to end.
Ends with the invariant: a route names a permission or states why it needs none. Same argument
as Unbounded/Anonymous — the claim cannot arrive by omission.
A′-cloud covers the 30 /admin/api routes, which need a decision rather than a default:
shellhub-io/team#238.
Deferred, and why
Per-resource conversion of the remaining ~160 handlers — the bulk of the work, but it wants
its own planning pass once A and B fix the shape of a converted route. Each resource carries
scope-into-the-service and actor-instead-of-req.UserID for that resource.
gateway.Context deletion (15 accessors, duplicate pairs, GetTennat) — falls out of the
conversion, not before it.
MCP calling handlers directly — needs A, because the httptest round-trip is currently what
re-runs the permission check.
Authorization decided in handlers (req.Manage, the silent req.All downgrade, GetNamespace's membership check) — behaviour changes; keep them out of mechanical PRs so they
are reviewed as such.
The tenant guard and the legacy authorize middleware — the latter looks close to vestigial,
and deciding its fate is a behaviour question.
Collapsing the anonymous allowlist into the route table — the allowlist must survive for
routes that never touch the gateway, and registering from the mount call needs an authenticator
the mount helper does not have. The all-routes anonymity invariant added in refactor(server): make route registration and declaration the same act #7030 is what makes
this safe to do later.
CI
A PR based on a feature branch runs zero checks in these repos: the workflows are gated on branches: [master]. Every PR in this stack needs lint and tests run locally, in-container, before
it is called green.
The roadmap for the handler conversion, recovered from a local planning file that was never
committed. It is here so the stack has a link.
Decision
Deepen the route declaration seam before converting the remaining ~160 handlers.
#6941 gave routes three shapes and a
gateway.Declarationrecording what aroute claims. #7030 made mounting and declaring the same act, so a declaration
now knows its own address, permission and API-key policy, and the route-table tests are invariants
over every route the router mounts rather than a hand-maintained ledger.
Stack
The original plan ordered this
A → A′ → B, with cloud following one step behind at each rung.Reordered for two reasons:
cloud/does not compile against #7030 untilA-cloudlands, whichtaxes every change made in the workspace meanwhile; and
A′is a security review needing a humananswer per route, so it should not block a mechanical cleanup.
Bdepends onAonly —Acceptsis a
RouteOptionon the mount call, which #7030 delivered.A-cloud — cloud adopts the same registration
Spec: shellhub-io/team#239.
The four shape constructors changed return type in #7030, so
internal/billing/routes,internal/admin/routesandinternal/cloud/routesno longer build. Mechanical: the same diff shapeas #7030 over 81 call sites, with
Requires/NoAPIKey/Guardalready built and tested. Cloudregisters no declarations today, so the audits do not yet see it.
Note the billing webhook registers on the router root, and the two admin groups are groups — all
three are covered by
gateway.MountOn(router, target).B — the list-query contract belongs to the List shape
Goal. Delete the 14 shellhub (and later 7 cloud) copies of normalize → unmarshal → validate →
log → 400, and the 171-line
go/asttest that polices them.Shape.
gateway.Accepts(services.DeviceQuery)on the registration;prepare[T]runs thecontract before the handler is entered, where it already normalizes the paginator and sorter. The
per-resource
FieldSet/FieldConstraintspairs move behind one named value per resource.Carries these fixes. The
Listshape ownsX-Total-Count, so the three endpoints answering itwith
len(list)— the page size, not the collection size — are corrected as a side effect:access-policy.go:32,service-account.go:34,ssh-identity.go:48.invitation.go'sFormatInt/Itoasplit goes with them. The default sort columns picked inapi-key.go:52andinstall-key.go:57move to the resource's contract, where they are policy rather than handler code.Deletes.
server/api/routes/list_validation_test.go, replaced by a check overDeclarations():a
Listroute names a query contract.A′ — every route states a permission claim, or why it needs none
A security review, not a mechanical diff. 21 mutating shellhub routes carry no permission today.
Most are legitimately self-service or pre-credential (auth, register, accept invite, leave
namespace, web reauth, setup) and want
gateway.NoPermission("...")with a written reason.One is not what the route table suggests:
DeleteSSHIdentityregisters with no permission while itstwo siblings require
SSHIdentityAdd. It is guarded, in the handler body, so the behaviour iscorrect — the route table under-reports it, which is exactly the class of thing declaring the
permission at the registration is meant to end.
Ends with the invariant: a route names a permission or states why it needs none. Same argument
as
Unbounded/Anonymous— the claim cannot arrive by omission.A′-cloudcovers the 30/admin/apiroutes, which need a decision rather than a default:shellhub-io/team#238.
Deferred, and why
its own planning pass once A and B fix the shape of a converted route. Each resource carries
scope-into-the-service and actor-instead-of-
req.UserIDfor that resource.gateway.Contextdeletion (15 accessors, duplicate pairs,GetTennat) — falls out of theconversion, not before it.
httptestround-trip is currently whatre-runs the permission check.
req.Manage, the silentreq.Alldowngrade,GetNamespace's membership check) — behaviour changes; keep them out of mechanical PRs so theyare reviewed as such.
and deciding its fate is a behaviour question.
routes that never touch the gateway, and registering from the mount call needs an authenticator
the mount helper does not have. The all-routes anonymity invariant added in refactor(server): make route registration and declaration the same act #7030 is what makes
this safe to do later.
CI
A PR based on a feature branch runs zero checks in these repos: the workflows are gated on
branches: [master]. Every PR in this stack needs lint and tests run locally, in-container, beforeit is called green.