Summary
The hub has no way to revoke a client token once issued. Tokens minted by Store.RegisterClient (internal/hub/store.go:147) live forever — there's no Revoke, DeleteClient, UnregisterClient, or equivalent on the Store, no corresponding RPC handler in grpc.go, and no CLI command surface. If a token leaks (shared credential file, accidental commit, compromised dev machine), the only mitigation is restarting the hub from a fresh persistence directory — which invalidates every other client's token at the same time.
This was tracked in TASKS.md as part of a broader "prevent duplicate client registration + add token revocation" entry (added 2026-04-08). The duplicate-registration half is already done; this issue covers only the revocation gap.
What's already in place
Store.RegisterClient rejects duplicate project names (good):
// internal/hub/store.go:147-164
func (s *Store) RegisterClient(client ClientInfo) error {
s.mu.Lock()
defer s.mu.Unlock()
// Reject duplicate project names.
for i := range s.clients {
if s.clients[i].ProjectName == client.ProjectName {
return errHub.DuplicateProject(
client.ProjectName,
)
}
}
...
}
No corresponding regression test for the duplicate-rejection path, but that's a small follow-up (see "Out of scope" below).
What's missing
- No
Store.RevokeClient (or equivalent). Grep on Revoke|DeleteClient|UnregisterClient|RemoveClient in internal/hub/ returns zero hits.
- No RPC method.
internal/hub/grpc.go registers only Register, Publish, and Status handlers.
- No CLI surface.
ctx hub (or wherever the management commands live) has no revoke / client rm / similar.
- No persistence story. When a token is revoked,
s.clients and s.tokenIdx need a coordinated update, and clientsPath(s.dir) needs to be rewritten atomically.
Proposed Shape
Three layers, each small:
1. Store.RevokeClient(id string) error
// internal/hub/store.go
func (s *Store) RevokeClient(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.clients {
if s.clients[i].ID != id {
continue
}
delete(s.tokenIdx, s.clients[i].Token)
s.clients = append(s.clients[:i], s.clients[i+1:]...)
// tokenIdx still points to indices ≥ i; rebuild to keep
// them coherent. Cheap because the registry is small.
s.tokenIdx = make(map[string]int, len(s.clients))
for j := range s.clients {
s.tokenIdx[s.clients[j].Token] = j
}
return saveJSON(clientsPath(s.dir), s.clients)
}
return errHub.UnknownClient(id)
}
Key by ClientInfo.ID (a stable identifier) rather than ProjectName (which a future operator might rename) or Token (which the operator presumably doesn't have to hand if they're trying to revoke a leaked one).
A sibling RevokeByProject(name string) error is also reasonable since operators usually remember "the customer project that just leaked" before "the client UUID 7f3a..."
2. RPC + handler
Add a Revoke RPC to the admin surface. Should be admin-token-gated (matches how Register handles admin auth in the current handler).
// internal/hub/handler.go
type RevokeRequest struct {
AdminToken string
ClientID string // or ProjectName, see above
}
type RevokeResponse struct{}
func (s *Server) handleRevoke(req *RevokeRequest) (*RevokeResponse, error) {
if subtle.ConstantTimeCompare(
[]byte(req.AdminToken), []byte(s.adminToken),
) != 1 {
return nil, status.Error(codes.Unauthenticated, "bad admin token")
}
if err := s.store.RevokeClient(req.ClientID); err != nil {
return nil, err
}
return &RevokeResponse{}, nil
}
Wire via cfgHub.PathRevoke constant + makeRevokeHandler(s) registration in grpc.go.
3. Client wrapper + CLI
Client.Revoke(ctx, adminToken, clientID) error mirrors the existing Client.Register shape. Then a CLI command — ctx hub revoke <client-id> or ctx hub client rm <client-id> — that takes the admin token from $CTX_HUB_ADMIN_TOKEN or a flag.
Tests Required
TestStore_RevokeClient_RemovesByID: register two clients, revoke one by ID, assert the other still validates and the revoked one's token fails ValidateToken.
TestStore_RevokeClient_UnknownIDReturnsError: revoke a nonexistent ID, assert errHub.UnknownClient (or whatever typed error gets defined).
TestStore_RevokeClient_PersistsAcrossRestart: revoke, close+reopen the Store from the same directory, assert the revocation survived.
TestServer_Revoke_RequiresAdminToken: call the Revoke RPC with a non-admin bearer, assert Unauthenticated.
- (Companion)
TestStore_RegisterClient_RejectsDuplicateProject: regression-pin the already-implemented duplicate rejection. Out of scope per "Out of scope" below, but trivially small and worth landing in the same PR if convenient.
Out of Scope
- The
RegisterClient duplicate-rejection itself — already implemented. A test for it would be welcome but doesn't need to block this issue.
- Token TTL / scheduled rotation (e.g., tokens expire after 90 days). Different concern; revocation is the prerequisite.
- Audit log of revocations. Possibly worth doing alongside (the structured log surface at
internal/log/event would be the natural target), but the minimum useful surface is revocation itself.
- Self-service revocation (a client revoking its own token). Admin-only is the simpler v1.
Acceptance
Summary
The hub has no way to revoke a client token once issued. Tokens minted by
Store.RegisterClient(internal/hub/store.go:147) live forever — there's noRevoke,DeleteClient,UnregisterClient, or equivalent on the Store, no corresponding RPC handler ingrpc.go, and no CLI command surface. If a token leaks (shared credential file, accidental commit, compromised dev machine), the only mitigation is restarting the hub from a fresh persistence directory — which invalidates every other client's token at the same time.This was tracked in TASKS.md as part of a broader "prevent duplicate client registration + add token revocation" entry (added 2026-04-08). The duplicate-registration half is already done; this issue covers only the revocation gap.
What's already in place
Store.RegisterClientrejects duplicate project names (good):No corresponding regression test for the duplicate-rejection path, but that's a small follow-up (see "Out of scope" below).
What's missing
Store.RevokeClient(or equivalent). Grep onRevoke|DeleteClient|UnregisterClient|RemoveClientininternal/hub/returns zero hits.internal/hub/grpc.goregisters onlyRegister,Publish, andStatushandlers.ctx hub(or wherever the management commands live) has norevoke/client rm/ similar.s.clientsands.tokenIdxneed a coordinated update, andclientsPath(s.dir)needs to be rewritten atomically.Proposed Shape
Three layers, each small:
1.
Store.RevokeClient(id string) errorKey by
ClientInfo.ID(a stable identifier) rather thanProjectName(which a future operator might rename) orToken(which the operator presumably doesn't have to hand if they're trying to revoke a leaked one).A sibling
RevokeByProject(name string) erroris also reasonable since operators usually remember "the customer project that just leaked" before "the client UUID 7f3a..."2. RPC + handler
Add a
RevokeRPC to the admin surface. Should be admin-token-gated (matches how Register handles admin auth in the current handler).Wire via
cfgHub.PathRevokeconstant +makeRevokeHandler(s)registration ingrpc.go.3. Client wrapper + CLI
Client.Revoke(ctx, adminToken, clientID) errormirrors the existingClient.Registershape. Then a CLI command —ctx hub revoke <client-id>orctx hub client rm <client-id>— that takes the admin token from$CTX_HUB_ADMIN_TOKENor a flag.Tests Required
TestStore_RevokeClient_RemovesByID: register two clients, revoke one by ID, assert the other still validates and the revoked one's token fails ValidateToken.TestStore_RevokeClient_UnknownIDReturnsError: revoke a nonexistent ID, asserterrHub.UnknownClient(or whatever typed error gets defined).TestStore_RevokeClient_PersistsAcrossRestart: revoke, close+reopen the Store from the same directory, assert the revocation survived.TestServer_Revoke_RequiresAdminToken: call the Revoke RPC with a non-admin bearer, assert Unauthenticated.TestStore_RegisterClient_RejectsDuplicateProject: regression-pin the already-implemented duplicate rejection. Out of scope per "Out of scope" below, but trivially small and worth landing in the same PR if convenient.Out of Scope
RegisterClientduplicate-rejection itself — already implemented. A test for it would be welcome but doesn't need to block this issue.internal/log/eventwould be the natural target), but the minimum useful surface is revocation itself.Acceptance
Store.RevokeClientlands with tests for happy path, unknown ID, and persistence-across-restart.ValidateTokenimmediately (no in-memory staleness).