Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/assets/commands/commands.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ hub:
status Show cluster status
peer Add or remove cluster peers
stepdown Transfer leadership to another node
revoke Revoke a client token

See `ctx hub <subcommand> --help` for details. For client-side
setup (register, subscribe, sync, listen, publish), see
Expand Down Expand Up @@ -429,6 +430,18 @@ hub.stepdown:
before the current leader steps down. Use before taking a
node offline for maintenance.
short: Transfer leadership
hub.revoke:
long: |-
Revoke a client's token, invalidating it immediately.

Identify the client by its ID (as shown when it was
registered). The revoked token stops authenticating on the
next request; other clients are unaffected.

Requires the hub admin token, supplied via --token or the
CTX_HUB_ADMIN_TOKEN environment variable. The hub address is
read from the saved connection config.
short: Revoke a client token
hook:
long: |-
Manage hook-related settings: messages, notifications,
Expand Down
2 changes: 2 additions & 0 deletions internal/assets/commands/flags.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ hub.start.port:
short: Hub listen port (default 9900)
hub.stop.data-dir:
short: Hub data directory (default ~/.ctx/hub-data/)
hub.revoke.token:
short: Admin credential from hub startup (or $CTX_HUB_ADMIN_TOKEN)
watch.dry-run:
short: Show updates without applying
watch.log:
Expand Down
4 changes: 4 additions & 0 deletions internal/assets/commands/text/errors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,10 @@ err.hub.internal:
short: 'internal: %w'
err.hub.duplicate-project:
short: 'project already registered: %q'
err.hub.unknown-client:
short: 'unknown client: %q'
err.hub.admin-token-required:
short: 'admin token required: pass --token or set $CTX_HUB_ADMIN_TOKEN'
err.hub.invalid-peer-action:
short: "action must be 'add' or 'remove', got %q"
err.serve.no-running-hub:
Expand Down
2 changes: 2 additions & 0 deletions internal/assets/commands/text/write.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,8 @@ write.hub-added-peer:
short: 'Added peer %s'
write.hub-removed-peer:
short: 'Removed peer %s'
write.hub-revoked:
short: 'Revoked client %s'
write.hub-leadership-transferred:
short: Leadership transferred
write.hub-leader:
Expand Down
66 changes: 66 additions & 0 deletions internal/cli/hub/cmd/revoke/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package revoke

import (
"os"

"github.com/spf13/cobra"

"github.com/ActiveMemory/ctx/internal/assets/read/desc"
coreRevoke "github.com/ActiveMemory/ctx/internal/cli/hub/core/revoke"
"github.com/ActiveMemory/ctx/internal/config/cli"
"github.com/ActiveMemory/ctx/internal/config/embed/cmd"
"github.com/ActiveMemory/ctx/internal/config/embed/flag"
"github.com/ActiveMemory/ctx/internal/config/env"
cFlag "github.com/ActiveMemory/ctx/internal/config/flag"
errHub "github.com/ActiveMemory/ctx/internal/err/hub"
"github.com/ActiveMemory/ctx/internal/flagbind"
)

// Cmd returns the hub revoke subcommand.
//
// Returns:
// - *cobra.Command: The revoke subcommand
func Cmd() *cobra.Command {
var adminToken string

short, long := desc.Command(cmd.DescKeyHubRevoke)

c := &cobra.Command{
Use: cmd.UseHubRevoke,
Short: short,
Long: long,
Example: desc.Example(cmd.DescKeyHubRevoke),
Args: cobra.ExactArgs(1),
// Hub stores at ~/.ctx/hub-data/, not .context/.
// Spec: specs/single-source-context-anchor.md.
Annotations: map[string]string{cli.AnnotationSkipInit: cli.AnnotationTrue},
RunE: func(
cobraCmd *cobra.Command, args []string,
) error {
// Admin token: --token flag takes precedence, then
// the CTX_HUB_ADMIN_TOKEN environment variable.
token := adminToken
if token == "" {
token = os.Getenv(env.HubAdminToken)
}
if token == "" {
cobraCmd.SilenceUsage = true
return errHub.AdminTokenRequired()
}
return coreRevoke.Run(cobraCmd, args[0], token)
},
}

flagbind.StringFlag(
c, &adminToken,
cFlag.Token, flag.DescKeyHubRevokeToken,
)

return c
}
23 changes: 23 additions & 0 deletions internal/cli/hub/cmd/revoke/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

// Package revoke wires the ctx hub revoke subcommand.
//
// # Overview
//
// [Cmd] builds the cobra command that revokes a client's token
// on the hub. It takes the target client ID as its single
// positional argument and the admin token from the --token flag
// or the CTX_HUB_ADMIN_TOKEN environment variable, then
// delegates to the core revoke package.
//
// # Behavior
//
// The command resolves the admin token (flag first, then
// environment) and fails early with a clear error if neither is
// set. Like the other hub subcommands it skips context init,
// since the hub lives at ~/.ctx/hub-data/ rather than .context/.
package revoke
36 changes: 36 additions & 0 deletions internal/cli/hub/core/revoke/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

// Package revoke implements client token revocation for the
// ctx hub revoke command.
//
// # Overview
//
// This package revokes a registered client's token on a remote
// hub, invalidating it immediately. It is the operator-side
// counterpart to registration: where registration mints a
// token, revocation destroys one.
//
// # Behavior
//
// [Run] dials the hub via gRPC and calls the admin-gated Revoke
// RPC. Authentication uses the hub admin token (resolved from
// the --token flag or the CTX_HUB_ADMIN_TOKEN environment
// variable by the command layer), not the stored bearer token.
//
// # Data Flow
//
// When [Run] is called it performs these steps:
//
// 1. Loads connection config to obtain the hub address
// (same source as ctx hub status).
// 2. Dials the hub via gRPC using hub.NewClient with no
// bearer token; the Revoke RPC is admin-gated instead.
// 3. Calls the Revoke RPC with the admin token and the
// target client ID.
// 4. On success, delegates to writeHub.Revoked to confirm
// the revocation to the user.
package revoke
64 changes: 64 additions & 0 deletions internal/cli/hub/core/revoke/revoke.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package revoke

import (
"context"

"github.com/spf13/cobra"

connectCfg "github.com/ActiveMemory/ctx/internal/cli/connection/core/config"
cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn"
"github.com/ActiveMemory/ctx/internal/hub"
logWarn "github.com/ActiveMemory/ctx/internal/log/warn"
writeHub "github.com/ActiveMemory/ctx/internal/write/hub"
)

// Run revokes a client's token on the hub.
//
// The hub address is read from the saved connection config
// (same as `ctx hub status`). Authentication uses the admin
// token, not the stored bearer token, so a fresh client is
// dialed without one.
//
// Parameters:
// - cmd: cobra command for output
// - clientID: ID of the client to revoke
// - adminToken: hub admin token (already resolved from flag
// or environment by the caller)
//
// Returns:
// - error: non-nil if config load, dial, or revocation fails
func Run(
cmd *cobra.Command,
clientID string,
adminToken string,
) error {
cfg, loadErr := connectCfg.Load()
if loadErr != nil {
return loadErr
}

client, dialErr := hub.NewClient(cfg.HubAddr, "")
if dialErr != nil {
return dialErr
}
defer func() {
if cerr := client.Close(); cerr != nil {
logWarn.Warn(cfgWarn.CloseHubClient, cerr)
}
}()

if revErr := client.Revoke(
context.Background(), adminToken, clientID,
); revErr != nil {
return revErr
}

writeHub.Revoked(cmd, clientID)
return nil
}
4 changes: 3 additions & 1 deletion internal/cli/hub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"

"github.com/ActiveMemory/ctx/internal/cli/hub/cmd/peer"
"github.com/ActiveMemory/ctx/internal/cli/hub/cmd/revoke"
"github.com/ActiveMemory/ctx/internal/cli/hub/cmd/start"
hubStatus "github.com/ActiveMemory/ctx/internal/cli/hub/cmd/status"
"github.com/ActiveMemory/ctx/internal/cli/hub/cmd/stepdown"
Expand All @@ -22,7 +23,7 @@ import (
//
// Returns:
// - *cobra.Command: hub with start, stop, status, peer,
// stepdown
// stepdown, revoke
func Cmd() *cobra.Command {
return parent.Cmd(
cmd.DescKeyHub, cmd.UseHub,
Expand All @@ -31,5 +32,6 @@ func Cmd() *cobra.Command {
hubStatus.Cmd(),
peer.Cmd(),
stepdown.Cmd(),
revoke.Cmd(),
)
}
4 changes: 4 additions & 0 deletions internal/config/embed/cmd/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const (
UseHubPeer = "peer <add|remove> <address>"
// UseHubStepdown is the Use string for hub stepdown.
UseHubStepdown = "stepdown"
// UseHubRevoke is the Use string for hub revoke.
UseHubRevoke = "revoke <client-id>"

// DescKeyHub is the desc key for the hub command.
DescKeyHub = "hub"
Expand All @@ -33,4 +35,6 @@ const (
DescKeyHubPeer = "hub.peer"
// DescKeyHubStepdown is the desc key for hub stepdown.
DescKeyHubStepdown = "hub.stepdown"
// DescKeyHubRevoke is the desc key for hub revoke.
DescKeyHubRevoke = "hub.revoke"
)
2 changes: 2 additions & 0 deletions internal/config/embed/flag/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@
DescKeyHubStartPeers = "hub.start.peers"
// DescKeyHubStopDataDir is the text key for hub stop --data-dir.
DescKeyHubStopDataDir = "hub.stop.data-dir"
// DescKeyHubRevokeToken is the text key for hub revoke --token.
DescKeyHubRevokeToken = "hub.revoke.token"

Check failure on line 22 in internal/config/embed/flag/hub.go

View workflow job for this annotation

GitHub Actions / Lint

G101: Potential hardcoded credentials (gosec)
)
6 changes: 6 additions & 0 deletions internal/config/embed/text/err_hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ const (
// DescKeyErrHubDuplicateProject is the text key for
// duplicate project registration errors.
DescKeyErrHubDuplicateProject = "err.hub.duplicate-project"
// DescKeyErrHubUnknownClient is the text key for a
// revocation targeting a client ID that is not registered.
DescKeyErrHubUnknownClient = "err.hub.unknown-client"
// DescKeyErrHubAdminTokenRequired is the text key for an
// admin-gated command invoked with no admin token supplied.
DescKeyErrHubAdminTokenRequired = "err.hub.admin-token-required"
// DescKeyErrHubInvalidPeerAction is the text key for
// unrecognized peer action errors.
DescKeyErrHubInvalidPeerAction = "err.hub.invalid-peer-action"
Expand Down
3 changes: 3 additions & 0 deletions internal/config/embed/text/write_hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ const (
// DescKeyWriteHubClusterStats is the text key for hub
// cluster statistics.
DescKeyWriteHubClusterStats = "write.hub-cluster-stats"
// DescKeyWriteHubRevoked is the text key for the hub client
// revocation confirmation.
DescKeyWriteHubRevoked = "write.hub-revoked"
)
5 changes: 5 additions & 0 deletions internal/config/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ const (
// SkipPathCheck is the environment variable that skips the PATH
// validation during init. Set to True in tests.
SkipPathCheck = "CTX_SKIP_PATH_CHECK"
// HubAdminToken is the environment variable holding the hub
// admin token, used as a fallback when --token is not passed
// to admin-gated commands like `ctx hub revoke`.
//nolint:gosec // G101: env var name, not a credential
HubAdminToken = "CTX_HUB_ADMIN_TOKEN"
)

// Environment toggle values.
Expand Down
7 changes: 7 additions & 0 deletions internal/config/hub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const (
MethodListen = "Listen"
// MethodStatus is the Status RPC method name.
MethodStatus = "Status"
// MethodRevoke is the Revoke RPC method name.
MethodRevoke = "Revoke"
)

// Full gRPC method paths (ServicePath + MethodName).
Expand All @@ -41,6 +43,8 @@ const (
PathListen = ServicePath + MethodListen
// PathStatus is the full gRPC path for Status.
PathStatus = ServicePath + MethodStatus
// PathRevoke is the full gRPC path for Revoke.
PathRevoke = ServicePath + MethodRevoke
)

// Authorization header.
Expand Down Expand Up @@ -195,6 +199,9 @@ const (
ErrInvalidAdminToken = "invalid admin token"
// ErrProjectNameRequired is the gRPC error for missing project name.
ErrProjectNameRequired = "project_name required"
// ErrClientIDRequired is the gRPC error for a missing client ID
// on the Revoke RPC.
ErrClientIDRequired = "client_id required"
// ErrMissingMetadata is the gRPC error for missing metadata.
ErrMissingMetadata = "missing metadata"
// ErrMissingToken is the gRPC error for missing auth token.
Expand Down
28 changes: 28 additions & 0 deletions internal/err/hub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package hub

import (
"errors"
"fmt"

"github.com/ActiveMemory/ctx/internal/assets/read/desc"
Expand Down Expand Up @@ -53,6 +54,33 @@ func DuplicateProject(name string) error {
)
}

// UnknownClient returns an error when no registered client
// matches the given ID (e.g. an operator tries to revoke a
// client that was never registered or was already revoked).
//
// Parameters:
// - id: the client ID that could not be found
//
// Returns:
// - error: "unknown client: <id>"
func UnknownClient(id string) error {
return fmt.Errorf(
desc.Text(text.DescKeyErrHubUnknownClient), id,
)
}

// AdminTokenRequired returns an error when an admin-gated
// command is run without an admin token supplied via either the
// --token flag or the CTX_HUB_ADMIN_TOKEN environment variable.
//
// Returns:
// - error: guidance on how to supply the admin token
func AdminTokenRequired() error {
return errors.New(
desc.Text(text.DescKeyErrHubAdminTokenRequired),
)
}

// InvalidPeerAction returns an error for an unrecognized
// peer action.
//
Expand Down
Loading
Loading