From 77764e43e32c3eb936f33c1a05af3db0105d8a31 Mon Sep 17 00:00:00 2001 From: Jared Rickert Date: Sun, 27 Sep 2026 09:37:38 -0500 Subject: [PATCH] feat(invitation): accept Hub invitations for memberships and grants The Hub now offers org membership and keg grants as invitations that take effect only when accepted. tap namespace add-member and tap keg grant report whether a change was immediate or an invitation, and tap invitation lists, accepts, declines, and withdraws them. MCP gains a read-only invitation_list tool. Accepting access is the account holder's consent decision, so accept and decline stay off MCP; the parity exemption records why. --- docs/README.md | 13 +- docs/ai-coding-agents/mcp-setup.md | 1 + docs/development/feature-organization.md | 5 + integrations/content/tool-inventory.md | 6 +- .../claude/tapper/skills/tapper/SKILL.md | 6 +- .../codex/tapper/skills/tapper/SKILL.md | 6 +- .../opencode/tapper/skills/tapper/SKILL.md | 6 +- pkg/cli/cmd_invitation.go | 135 ++++++++++++++++++ pkg/cli/cmd_keg.go | 11 +- pkg/cli/cmd_namespace.go | 13 +- pkg/cli/cmd_root.go | 1 + pkg/mcp/providers.go | 3 + pkg/mcp/session_flight.go | 2 +- pkg/mcp/session_transition_test.go | 2 +- pkg/mcp/tools_namespace.go | 36 ++++- pkg/parity/parity_coverage_test.go | 4 + pkg/tapper/hub_grants.go | 14 +- pkg/tapper/hub_invitations.go | 82 +++++++++++ pkg/tapper/hub_namespaces.go | 11 +- pkg/tapper/tap_invitation.go | 72 ++++++++++ pkg/tapper/tap_invitation_test.go | 54 +++++++ pkg/tapper/tap_keg.go | 11 +- pkg/tapper/tap_keg_test.go | 20 ++- pkg/tapper/tap_namespace.go | 11 +- pkg/tapper/tap_namespace_test.go | 5 +- 25 files changed, 494 insertions(+), 36 deletions(-) create mode 100644 pkg/cli/cmd_invitation.go create mode 100644 pkg/tapper/hub_invitations.go create mode 100644 pkg/tapper/tap_invitation.go create mode 100644 pkg/tapper/tap_invitation_test.go diff --git a/docs/README.md b/docs/README.md index 70a300e0..295d9797 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,7 +66,14 @@ tap keg rename @acme/engineering docs ``` Namespace membership handles organization-level access. Keg grants handle -per-keg access when a domain needs a tighter boundary. +per-keg access when a domain needs a tighter boundary. Adding a member or a +grantee sends an invitation: access starts only when they accept it. + +```bash +tap invitation list # invitations addressed to you +tap invitation accept 42 # or: tap invitation decline 42 +tap invitation revoke 42 # withdraw one you sent +``` ### Connect An Agent @@ -115,7 +122,9 @@ writing KEG files directly. - `tap keg visibility public|private` - set keg visibility. - `tap keg rename @namespace/old new` - rename a keg alias in its namespace. - `tap namespace create|list|members|add-member|set-role|remove-member` - - manage namespaces and membership. + manage namespaces and membership. Member management requires the owner role. +- `tap invitation list|accept|decline|revoke` - review invitations to + namespaces and kegs; access starts only when accepted. - `tap hub list|status|add|remove|set-default` - manage hub connections. ### Safety And Operations diff --git a/docs/ai-coding-agents/mcp-setup.md b/docs/ai-coding-agents/mcp-setup.md index 59572f08..3af02625 100644 --- a/docs/ai-coding-agents/mcp-setup.md +++ b/docs/ai-coding-agents/mcp-setup.md @@ -191,6 +191,7 @@ vocabulary to ask. | `session_info` | Return structured credential-safe `identities[]` and exact pinned-root-context `kegs[]` | | `namespace_list` | List the namespaces you belong to, with your role | | `namespace_search` | Search the people and org namespaces visible on the Hub; discovery only | +| `invitation_list` | List pending invitations to namespaces and kegs addressed to you; read-only, since accepting is the user's decision | Each identity includes only its hub locator, user ID, username, display name, default namespace, and namespace names. Tokens, email, scopes, cookies, expiry, diff --git a/docs/development/feature-organization.md b/docs/development/feature-organization.md index 3d6577c4..0643e78b 100644 --- a/docs/development/feature-organization.md +++ b/docs/development/feature-organization.md @@ -27,6 +27,11 @@ features at parity. - Tests verify both surfaces produce equivalent results for the same input. - Docs document features, not surfaces — one description covers both CLI and MCP usage. +- The only exceptions are recorded, with a reason, in the exemption map in + `pkg/parity/parity_coverage_test.go`. Consent decisions are one class: + accepting or declining an invitation to a namespace or keg is the account + holder's own choice, so MCP exposes `invitation_list` read-only and never + accept or decline. ## Checklist diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md index 87e131e6..a9ead7dd 100644 --- a/integrations/content/tool-inventory.md +++ b/integrations/content/tool-inventory.md @@ -7,7 +7,7 @@ flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, `flight_search`, `keg_search`, `namespace_list`, `namespace_search`, -`agent_list`, and `agent_read` do not accept `flight`. MCP resources use root +`invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. `flight` is an **operational** parameter, not a discovery-only one: `node_list`, @@ -174,6 +174,10 @@ and `session_refresh` are always available. Hub, not only your own, matched by name or display name; empty query browses. Discovery only: a match grants nothing. Follow up with `keg_search` and `flight_search`. +- `mcp__tapper__invitation_list`: pending invitations addressed to your user, + org memberships and keg grants that take effect only if accepted. Read-only: + accepting or declining is your user's decision (Hub account page or + `tap invitation`), so tell them rather than acting. - `mcp__tapper__flight_search`: literal reference/title/description search over readable flights; at most 50 deterministic metadata results, with a truncation diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md index c3cc376c..1dc60cb6 100644 --- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md @@ -51,7 +51,7 @@ flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, `flight_search`, `keg_search`, `namespace_list`, `namespace_search`, -`agent_list`, and `agent_read` do not accept `flight`. MCP resources use root +`invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. `flight` is an **operational** parameter, not a discovery-only one: `node_list`, @@ -218,6 +218,10 @@ and `session_refresh` are always available. Hub, not only your own, matched by name or display name; empty query browses. Discovery only: a match grants nothing. Follow up with `keg_search` and `flight_search`. +- `mcp__tapper__invitation_list`: pending invitations addressed to your user, + org memberships and keg grants that take effect only if accepted. Read-only: + accepting or declining is your user's decision (Hub account page or + `tap invitation`), so tell them rather than acting. - `mcp__tapper__flight_search`: literal reference/title/description search over readable flights; at most 50 deterministic metadata results, with a truncation diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md index c3cc376c..1dc60cb6 100644 --- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md @@ -51,7 +51,7 @@ flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, `flight_search`, `keg_search`, `namespace_list`, `namespace_search`, -`agent_list`, and `agent_read` do not accept `flight`. MCP resources use root +`invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. `flight` is an **operational** parameter, not a discovery-only one: `node_list`, @@ -218,6 +218,10 @@ and `session_refresh` are always available. Hub, not only your own, matched by name or display name; empty query browses. Discovery only: a match grants nothing. Follow up with `keg_search` and `flight_search`. +- `mcp__tapper__invitation_list`: pending invitations addressed to your user, + org memberships and keg grants that take effect only if accepted. Read-only: + accepting or declining is your user's decision (Hub account page or + `tap invitation`), so tell them rather than acting. - `mcp__tapper__flight_search`: literal reference/title/description search over readable flights; at most 50 deterministic metadata results, with a truncation diff --git a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md index c3cc376c..1dc60cb6 100644 --- a/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md +++ b/integrations/rendered/opencode/tapper/skills/tapper/SKILL.md @@ -51,7 +51,7 @@ flight exactly. With a real pinned root, omission selects that root and an explicit value selects the root or an accessible flattened descendant. `session_info`, `session_refresh`, `flight_list`, `flight_read`, `flight_search`, `keg_search`, `namespace_list`, `namespace_search`, -`agent_list`, and `agent_read` do not accept `flight`. MCP resources use root +`invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root authority while rendering graph-wide discovery. `flight` is an **operational** parameter, not a discovery-only one: `node_list`, @@ -218,6 +218,10 @@ and `session_refresh` are always available. Hub, not only your own, matched by name or display name; empty query browses. Discovery only: a match grants nothing. Follow up with `keg_search` and `flight_search`. +- `mcp__tapper__invitation_list`: pending invitations addressed to your user, + org memberships and keg grants that take effect only if accepted. Read-only: + accepting or declining is your user's decision (Hub account page or + `tap invitation`), so tell them rather than acting. - `mcp__tapper__flight_search`: literal reference/title/description search over readable flights; at most 50 deterministic metadata results, with a truncation diff --git a/pkg/cli/cmd_invitation.go b/pkg/cli/cmd_invitation.go new file mode 100644 index 00000000..12be26f8 --- /dev/null +++ b/pkg/cli/cmd_invitation.go @@ -0,0 +1,135 @@ +package cli + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/jlrickert/tapper/pkg/tapper" +) + +// printAccessChange reports the outcome of adding a member or grant: either an +// immediate role change or an invitation the user must still accept. +func printAccessChange(cmd *cobra.Command, change tapper.AccessChange, user, role string) { + user = "@" + strings.TrimPrefix(strings.TrimSpace(user), "@") + if change.Invited() { + fmt.Fprintf(cmd.OutOrStdout(), "invited %s as %s (invitation %d); access starts when they accept\n", user, role, change.InvitationID) + return + } + fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", user, role) +} + +// NewInvitationCmd manages pending invitations to org namespaces and kegs. +func NewInvitationCmd(deps *Deps) *cobra.Command { + cmd := &cobra.Command{ + Use: "invitation", + Aliases: []string{"invitations"}, + Short: "list, accept, decline, or withdraw invitations to namespaces and kegs", + Long: "Org membership and keg grants are offered, not imposed: access starts only when the invitee\n" + + "accepts. `tap invitation list` shows invitations addressed to you on the selected hub (--hub).", + } + cmd.AddCommand(newInvitationListCmd(deps), newInvitationDecideCmd(deps, "accept"), newInvitationDecideCmd(deps, "decline"), newInvitationRevokeCmd(deps)) + return cmd +} + +func newInvitationListCmd(deps *Deps) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "list invitations addressed to you", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + rows, err := deps.Tap.InvitationList(cmd.Context(), tapper.InvitationOptions{Hub: globalKegTarget(deps).Hub}) + if err != nil { + return err + } + for _, inv := range rows { + fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s\t%s\t%s\t@%s\t%s\n", inv.ID, inv.Kind, inv.Target, inv.Role, inv.InvitedBy, inv.ExpiresAt.Format("2006-01-02")) + } + return nil + }, + } +} + +func newInvitationDecideCmd(deps *Deps, action string) *cobra.Command { + cmd := &cobra.Command{ + Use: action + " ", + Short: action + " an invitation addressed to you", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseInvitationID(args[0]) + if err != nil { + return err + } + opts := tapper.InvitationOptions{Hub: globalKegTarget(deps).Hub, ID: id} + var inv *tapper.HubInvitation + if action == "accept" { + inv, err = deps.Tap.InvitationAccept(cmd.Context(), opts) + } else { + inv, err = deps.Tap.InvitationDecline(cmd.Context(), opts) + } + if err != nil { + return err + } + if action == "accept" { + fmt.Fprintf(cmd.OutOrStdout(), "joined %s as %s\n", inv.Target, inv.Role) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "declined the invitation to %s\n", inv.Target) + } + return nil + }, + } + cmd.ValidArgsFunction = invitationIDCompletion(deps) + return cmd +} + +func newInvitationRevokeCmd(deps *Deps) *cobra.Command { + return &cobra.Command{ + Use: "revoke ", + Short: "withdraw a pending invitation you can manage", + Long: "Withdraw a pending invitation. Requires the namespace owner role (memberships) or keg admin role (grants).", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseInvitationID(args[0]) + if err != nil { + return err + } + if err := deps.Tap.InvitationRevoke(cmd.Context(), tapper.InvitationOptions{Hub: globalKegTarget(deps).Hub, ID: id}); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "withdrew invitation %d\n", id) + return nil + }, + } +} + +func parseInvitationID(raw string) (int64, error) { + id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid invitation id %q", raw) + } + return id, nil +} + +// invitationIDCompletion completes the ids of invitations addressed to the +// caller, described by their target. +func invitationIDCompletion(deps *Deps) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 || deps.Tap == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + rows, err := deps.Tap.InvitationList(cmd.Context(), tapper.InvitationOptions{Hub: globalKegTarget(deps).Hub}) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var out []string + for _, inv := range rows { + id := strconv.FormatInt(inv.ID, 10) + if strings.HasPrefix(id, toComplete) { + out = append(out, id+"\t"+inv.Target+" as "+inv.Role) + } + } + return out, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/pkg/cli/cmd_keg.go b/pkg/cli/cmd_keg.go index 135427e7..2c894291 100644 --- a/pkg/cli/cmd_keg.go +++ b/pkg/cli/cmd_keg.go @@ -129,17 +129,24 @@ func newKegGrantCmd(deps *Deps) *cobra.Command { cmd := &cobra.Command{ Use: "grant <@user> ", Short: "grant a user a role on a keg (viewer|editor|admin)", - Long: "Grant a user a role on the keg selected by --keg/--namespace/--hub (default: the resolved keg).", + Long: "Grant a user a role on the keg selected by --keg/--namespace/--hub (default: the resolved keg).\n\n" + + "An existing grantee's role changes immediately. Anyone else is sent an invitation and gains access\n" + + "only after accepting it (tap invitation accept); withdraw a pending one with tap invitation revoke.", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { kt := globalKegTarget(deps) - return deps.Tap.KegGrant(cmd.Context(), tapper.KegGrantOptions{ + change, err := deps.Tap.KegGrant(cmd.Context(), tapper.KegGrantOptions{ Keg: kt.Keg, Namespace: kt.Namespace, Hub: kt.Hub, User: args[0], Role: args[1], }) + if err != nil { + return err + } + printAccessChange(cmd, change, args[0], args[1]) + return nil }, } cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/pkg/cli/cmd_namespace.go b/pkg/cli/cmd_namespace.go index 93576607..fed4ca43 100644 --- a/pkg/cli/cmd_namespace.go +++ b/pkg/cli/cmd_namespace.go @@ -123,17 +123,24 @@ func newNamespaceMembersCmd(deps *Deps) *cobra.Command { func newNamespaceAddMemberCmd(deps *Deps) *cobra.Command { cmd := &cobra.Command{ Use: "add-member <@user> ", - Short: "add a member to a namespace (owner|admin|member)", - Long: "Add a member to the namespace selected by --namespace/--hub (default: the resolved namespace).", + Short: "invite a member to a namespace (owner|admin|member)", + Long: "Invite a member to the namespace selected by --namespace/--hub (default: the resolved namespace).\n\n" + + "They join only after accepting the invitation (tap invitation accept). For an existing member the\n" + + "role changes immediately. Member management requires the owner role.", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { kt := globalKegTarget(deps) - return deps.Tap.NamespaceAddMember(cmd.Context(), tapper.NamespaceAddMemberOptions{ + change, err := deps.Tap.NamespaceAddMember(cmd.Context(), tapper.NamespaceAddMemberOptions{ Namespace: kt.Namespace, Hub: kt.Hub, User: args[0], Role: args[1], }) + if err != nil { + return err + } + printAccessChange(cmd, change, args[0], args[1]) + return nil }, } cmd.ValidArgsFunction = namespaceMemberRoleArgCompletion(deps, 1) diff --git a/pkg/cli/cmd_root.go b/pkg/cli/cmd_root.go index 7aebf6af..316051aa 100644 --- a/pkg/cli/cmd_root.go +++ b/pkg/cli/cmd_root.go @@ -335,6 +335,7 @@ func NewRootCmd(deps *Deps) *cobra.Command { subcommands = append(subcommands, NewKegCmd(deps), NewNamespaceCmd(deps), + NewInvitationCmd(deps), NewUseCmd(deps), NewBootstrapCmd(deps), configCmd, diff --git a/pkg/mcp/providers.go b/pkg/mcp/providers.go index e2ccc3f6..b83561e2 100644 --- a/pkg/mcp/providers.go +++ b/pkg/mcp/providers.go @@ -722,6 +722,9 @@ func (p localNamespaceProvider) ListNamespaces(ctx context.Context) ([]tapper.Hu func (p localNamespaceProvider) SearchNamespaces(ctx context.Context, query string) (tapper.NamespaceSearchResult, error) { return p.tap.NamespaceSearch(ctx, tapper.NamespaceSearchOptions{Query: query}) } +func (p localNamespaceProvider) ListInvitations(ctx context.Context) ([]tapper.HubInvitation, error) { + return p.tap.InvitationList(ctx, tapper.InvitationOptions{}) +} type localIdentityProvider struct{ tap *tapper.Tap } diff --git a/pkg/mcp/session_flight.go b/pkg/mcp/session_flight.go index bb49d131..087f986f 100644 --- a/pkg/mcp/session_flight.go +++ b/pkg/mcp/session_flight.go @@ -75,7 +75,7 @@ var ungovernedToolNames = map[string]bool{ "namespace_list": true, "namespace_create": true, "namespace_members": true, "namespace_add_member": true, "namespace_set_role": true, "namespace_remove_member": true, "license": true, "flight_list": true, "flight_read": true, - "namespace_search": true, "agent_list": true, "agent_read": true, + "namespace_search": true, "invitation_list": true, "agent_list": true, "agent_read": true, } // sessionMode is the authority state of one MCP session. diff --git a/pkg/mcp/session_transition_test.go b/pkg/mcp/session_transition_test.go index 2367bbf3..a2e17a73 100644 --- a/pkg/mcp/session_transition_test.go +++ b/pkg/mcp/session_transition_test.go @@ -630,7 +630,7 @@ func TestMCP_AuthorityBearingSchemasExposeOptionalFlightAndRejectKegListAll(t *t session, ctx := newPerCallFlightSession(t, backend) result, err := session.ListTools(ctx, nil) require.NoError(t, err) - ungoverned := map[string]bool{"guide": true, "flight_search": true, "session_info": true, "keg_search": true, "flight_list": true, "flight_read": true, "session_refresh": true, "namespace_list": true, "namespace_search": true, "agent_list": true, "agent_read": true} + ungoverned := map[string]bool{"guide": true, "flight_search": true, "session_info": true, "keg_search": true, "flight_list": true, "flight_read": true, "session_refresh": true, "namespace_list": true, "namespace_search": true, "invitation_list": true, "agent_list": true, "agent_read": true} seen := map[string]bool{} for _, tool := range result.Tools { seen[tool.Name] = true diff --git a/pkg/mcp/tools_namespace.go b/pkg/mcp/tools_namespace.go index 6ca5f9a0..21373fe6 100644 --- a/pkg/mcp/tools_namespace.go +++ b/pkg/mcp/tools_namespace.go @@ -5,6 +5,8 @@ import ( "fmt" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/jlrickert/tapper/pkg/tapper" ) type namespaceListInput struct{} @@ -13,11 +15,43 @@ type namespaceSearchInput struct { Query string `json:"query,omitempty" jsonschema:"text to match against namespace names and display names; empty browses"` } +// InvitationLister is implemented by namespace providers that can list the +// caller's pending invitations. It is optional so a provider without it still +// satisfies NamespaceProvider; invitation_list registers only when present. +type InvitationLister interface { + // ListInvitations returns the caller's pending, unexpired invitations. + ListInvitations(context.Context) ([]tapper.HubInvitation, error) +} + // registerNamespaceTools exposes namespace discovery over MCP, at parity with // `tap namespace list` and `tap namespace search`. Discovery never grants // authority. Member rosters and role changes stay UI-only, so those tools are -// intentionally not registered. +// intentionally not registered. Pending invitations are listed read-only: +// accepting or declining access is the account holder's own decision, made in +// the Hub UI or `tap invitation`, never by an agent. func registerNamespaceTools(srv *sdkmcp.Server, namespaces NamespaceProvider) { + if lister, ok := namespaces.(InvitationLister); ok { + sdkmcp.AddTool(srv, &sdkmcp.Tool{ + Name: "invitation_list", + Description: "List pending invitations addressed to you: org memberships and keg grants that take effect only if you accept them. Read-only; accepting or declining is your user's decision, made in the Hub UI or with `tap invitation`, so ask them rather than acting.", + Annotations: &sdkmcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: boolPtr(true), + }, + }, func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ namespaceListInput) (*sdkmcp.CallToolResult, any, error) { + rows, err := lister.ListInvitations(ctx) + if err != nil { + return errorResult(err), nil, nil + } + lines := make([]string, 0, len(rows)) + for _, inv := range rows { + lines = append(lines, fmt.Sprintf("%d\t%s\t%s\t%s\t@%s", inv.ID, inv.Kind, inv.Target, inv.Role, inv.InvitedBy)) + } + res := linesResult(lines) + res.StructuredContent = map[string]any{"invitations": rows} + return res, nil, nil + }) + } sdkmcp.AddTool(srv, &sdkmcp.Tool{ Name: "namespace_list", Description: "List the namespaces you belong to, with your role in each", diff --git a/pkg/parity/parity_coverage_test.go b/pkg/parity/parity_coverage_test.go index a9e60305..025bfa34 100644 --- a/pkg/parity/parity_coverage_test.go +++ b/pkg/parity/parity_coverage_test.go @@ -104,6 +104,7 @@ var tapMethodToSurfaces = map[string]struct { "HubListKegs": {CLI: "keg list", MCP: []string{"keg_list"}}, "NamespaceList": {CLI: "namespace list", MCP: []string{"namespace_list"}}, "NamespaceSearch": {CLI: "namespace search", MCP: []string{"namespace_search"}}, + "InvitationList": {CLI: "invitation list", MCP: []string{"invitation_list"}}, // Agent orientation remains shared. Native plugin installation is an // intentionally CLI-only host operation (see tapMethodsExcluded). @@ -153,6 +154,9 @@ var tapMethodsExcluded = map[string]string{ "KegGrants": "UI-only user/role inspection for now; MCP must not expose keg grants", "KegGrant": "UI-only user/role management for now; MCP must not mutate keg grants", "KegRevoke": "UI-only user/role management for now; MCP must not mutate keg grants", + "InvitationAccept": "security: accepting access is the account holder's consent decision; agents must not accept on their behalf", + "InvitationDecline": "security: declining access is the account holder's consent decision; agents must not decide on their behalf", + "InvitationRevoke": "UI-only user/role management for now; MCP must not withdraw invitations", "KegRename": "UI-only alias management for now; MCP must not rename kegs", "SetKeg": "CLI-only bootstrap step; persists the chosen keg as the user-level fallback after login, not an MCP operation", "SetBootstrapFlight": "CLI-only bootstrap step; validates and persists the user-level flight baseline, not an MCP operation", diff --git a/pkg/tapper/hub_grants.go b/pkg/tapper/hub_grants.go index c00a424a..9af63342 100644 --- a/pkg/tapper/hub_grants.go +++ b/pkg/tapper/hub_grants.go @@ -42,10 +42,14 @@ func ListGrants(ctx context.Context, hubURL, token, namespace, alias string) ([] return out, nil } -// CreateGrant upserts a grant via POST .../grants. role is viewer|editor|admin. -func CreateGrant(ctx context.Context, hubURL, token, namespace, alias, username, role string) error { +// CreateGrant sets a grant via POST .../grants. role is viewer|editor|admin. +// An existing grantee's role changes immediately; anyone else is invited and +// gains access only when they accept, which the result reports. +func CreateGrant(ctx context.Context, hubURL, token, namespace, alias, username, role string) (AccessChange, error) { payload := map[string]string{"username": strings.TrimPrefix(strings.TrimSpace(username), "@"), "role": role} - return doHubJSON(ctx, http.MethodPost, hubURL, token, kegGrantsPath(namespace, alias), payload, nil) + var out AccessChange + err := doHubJSON(ctx, http.MethodPost, hubURL, token, kegGrantsPath(namespace, alias), payload, &out) + return out, err } // RevokeGrant removes a grant via DELETE .../grants/@{username}. @@ -105,7 +109,7 @@ func doHubJSON(ctx context.Context, method, hubURL, token, path string, payload, defer func() { _ = resp.Body.Close() }() switch resp.StatusCode { - case http.StatusOK, http.StatusCreated: + case http.StatusOK, http.StatusCreated, http.StatusAccepted: case http.StatusNoContent: return nil case http.StatusConflict: @@ -116,6 +120,8 @@ func doHubJSON(ctx context.Context, method, hubURL, token, path string, payload, return fmt.Errorf("hub: %w (%s)%s", keg.ErrForbidden, resp.Status, readHubError(resp)) case http.StatusUnauthorized: return fmt.Errorf("hub: %w (%s)%s", ErrTokenRejected, resp.Status, readHubError(resp)) + case http.StatusGone: + return fmt.Errorf("hub: %w%s", ErrInvitationVoid, readHubError(resp)) default: return fmt.Errorf("hub: request failed: %s%s", resp.Status, readHubError(resp)) } diff --git a/pkg/tapper/hub_invitations.go b/pkg/tapper/hub_invitations.go new file mode 100644 index 00000000..be100558 --- /dev/null +++ b/pkg/tapper/hub_invitations.go @@ -0,0 +1,82 @@ +// Package tapper — hub invitation client calls. +// +// Org membership and keg grants are offered, not imposed: adding someone +// creates an invitation, and access starts only when its invitee accepts it. +// These talk to the hub's /api/v1/invitations endpoints over doHubJSON. +package tapper + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" +) + +// ErrInvitationVoid reports an invitation that can no longer be accepted +// because whoever sent it can no longer extend the access. +var ErrInvitationVoid = errors.New("invitation is no longer valid") + +// AccessChange is the result of adding a member or grant. Invited means the +// hub created an invitation: nothing changes until the invitee accepts it. +// Mirrors the hub's 202 body; a 200/201 role change leaves Invited false. +type AccessChange struct { + Status string `json:"status,omitempty"` + InvitationID int64 `json:"invitation_id,omitempty"` + Username string `json:"username,omitempty"` + Role string `json:"role,omitempty"` +} + +// Invited reports whether the change became an invitation. +func (c AccessChange) Invited() bool { return c.Status == "invited" } + +// HubInvitation is one pending invitation. Mirrors the hub's +// handler.invitationWire JSON body — keep the two in sync. +type HubInvitation struct { + ID int64 `json:"id"` + Kind string `json:"kind"` // membership|grant + Target string `json:"target"` // @ns or @ns/keg + Namespace string `json:"namespace"` + Keg string `json:"keg,omitempty"` + Username string `json:"username"` + Role string `json:"role"` + InvitedBy string `json:"invited_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + + // Hub names the configured hub this row came from. Filled in locally. + Hub string `json:"hub,omitempty"` +} + +// ListInvitations returns the caller's pending invitations via +// GET /api/v1/invitations. +func ListInvitations(ctx context.Context, hubURL, token string) ([]HubInvitation, error) { + var out []HubInvitation + if err := doHubJSON(ctx, http.MethodGet, hubURL, token, "/api/v1/invitations", nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// AcceptInvitation accepts an invitation addressed to the caller. +func AcceptInvitation(ctx context.Context, hubURL, token string, id int64) (*HubInvitation, error) { + var out HubInvitation + if err := doHubJSON(ctx, http.MethodPost, hubURL, token, fmt.Sprintf("/api/v1/invitations/%d/accept", id), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeclineInvitation discards an invitation addressed to the caller. +func DeclineInvitation(ctx context.Context, hubURL, token string, id int64) (*HubInvitation, error) { + var out HubInvitation + if err := doHubJSON(ctx, http.MethodPost, hubURL, token, fmt.Sprintf("/api/v1/invitations/%d/decline", id), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// RevokeInvitation withdraws a pending invitation the caller could extend. +func RevokeInvitation(ctx context.Context, hubURL, token string, id int64) error { + return doHubJSON(ctx, http.MethodDelete, hubURL, token, fmt.Sprintf("/api/v1/invitations/%d", id), nil, nil) +} diff --git a/pkg/tapper/hub_namespaces.go b/pkg/tapper/hub_namespaces.go index c95f5884..1ad698f5 100644 --- a/pkg/tapper/hub_namespaces.go +++ b/pkg/tapper/hub_namespaces.go @@ -49,11 +49,14 @@ func ListNamespaceMembers(ctx context.Context, hubURL, token, namespace string) return out, nil } -// AddNamespaceMember upserts a member via POST /api/v1/@{namespace}/members. -// role is owner|admin|member. -func AddNamespaceMember(ctx context.Context, hubURL, token, namespace, username, role string) error { +// AddNamespaceMember invites a member, or changes an existing member's role, +// via POST /api/v1/@{namespace}/members. role is owner|admin|member. A new +// member joins only when they accept, which the result reports. +func AddNamespaceMember(ctx context.Context, hubURL, token, namespace, username, role string) (AccessChange, error) { payload := map[string]string{"username": strings.TrimPrefix(strings.TrimSpace(username), "@"), "role": role} - return doHubJSON(ctx, http.MethodPost, hubURL, token, namespaceMembersPath(namespace), payload, nil) + var out AccessChange + err := doHubJSON(ctx, http.MethodPost, hubURL, token, namespaceMembersPath(namespace), payload, &out) + return out, err } // SetNamespaceMemberRole changes a member's role via diff --git a/pkg/tapper/tap_invitation.go b/pkg/tapper/tap_invitation.go new file mode 100644 index 00000000..69c3132d --- /dev/null +++ b/pkg/tapper/tap_invitation.go @@ -0,0 +1,72 @@ +package tapper + +import ( + "context" + "fmt" +) + +// InvitationOptions selects a hub (empty: the selected hub) and, for +// decisions, one invitation. +type InvitationOptions struct { + Hub string + ID int64 +} + +// InvitationList returns the caller's pending invitations on a hub. +func (t *Tap) InvitationList(ctx context.Context, opts InvitationOptions) ([]HubInvitation, error) { + name, entry, err := t.ConfigService.SelectedHub(opts.Hub) + if err != nil { + return nil, err + } + hubURL, token, err := remoteHubEndpoint(t, name, entry) + if err != nil { + return nil, err + } + rows, err := ListInvitations(ctx, hubURL, token) + if err != nil { + return nil, err + } + for i := range rows { + rows[i].Hub = name + } + return rows, nil +} + +// InvitationAccept accepts one of the caller's invitations. +func (t *Tap) InvitationAccept(ctx context.Context, opts InvitationOptions) (*HubInvitation, error) { + hubURL, token, err := t.invitationHub(opts) + if err != nil { + return nil, err + } + return AcceptInvitation(ctx, hubURL, token, opts.ID) +} + +// InvitationDecline declines one of the caller's invitations. +func (t *Tap) InvitationDecline(ctx context.Context, opts InvitationOptions) (*HubInvitation, error) { + hubURL, token, err := t.invitationHub(opts) + if err != nil { + return nil, err + } + return DeclineInvitation(ctx, hubURL, token, opts.ID) +} + +// InvitationRevoke withdraws a pending invitation the caller sent or could +// send (a namespace owner, or a keg admin). +func (t *Tap) InvitationRevoke(ctx context.Context, opts InvitationOptions) error { + hubURL, token, err := t.invitationHub(opts) + if err != nil { + return err + } + return RevokeInvitation(ctx, hubURL, token, opts.ID) +} + +func (t *Tap) invitationHub(opts InvitationOptions) (hubURL, token string, err error) { + if opts.ID <= 0 { + return "", "", fmt.Errorf("an invitation id is required") + } + name, entry, err := t.ConfigService.SelectedHub(opts.Hub) + if err != nil { + return "", "", err + } + return remoteHubEndpoint(t, name, entry) +} diff --git a/pkg/tapper/tap_invitation_test.go b/pkg/tapper/tap_invitation_test.go new file mode 100644 index 00000000..16541cce --- /dev/null +++ b/pkg/tapper/tap_invitation_test.go @@ -0,0 +1,54 @@ +package tapper_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/jlrickert/tapper/pkg/tapper" +) + +func TestInvitationListAcceptDeclineRevoke(t *testing.T) { + t.Parallel() + var calls []string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer tok", r.Header.Get("Authorization")) + calls = append(calls, r.Method+" "+r.URL.Path) + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/invitations": + _ = json.NewEncoder(w).Encode([]tapper.HubInvitation{{ID: 3, Kind: "grant", Target: "@acme/notes", Role: "editor"}}) + case "POST /api/v1/invitations/3/accept", "POST /api/v1/invitations/3/decline": + _ = json.NewEncoder(w).Encode(tapper.HubInvitation{ID: 3, Kind: "grant", Target: "@acme/notes", Role: "editor"}) + case "POST /api/v1/invitations/4/accept": + w.WriteHeader(http.StatusGone) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "stale"}) + case "DELETE /api/v1/invitations/3": + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + }) + tap, fx, _ := newRemoteHubTap(t, h) + ctx := fx.Context() + + rows, err := tap.InvitationList(ctx, tapper.InvitationOptions{}) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "@acme/notes", rows[0].Target) + require.NotEmpty(t, rows[0].Hub) + + inv, err := tap.InvitationAccept(ctx, tapper.InvitationOptions{ID: 3}) + require.NoError(t, err) + require.Equal(t, "editor", inv.Role) + _, err = tap.InvitationDecline(ctx, tapper.InvitationOptions{ID: 3}) + require.NoError(t, err) + require.NoError(t, tap.InvitationRevoke(ctx, tapper.InvitationOptions{ID: 3})) + + _, err = tap.InvitationAccept(ctx, tapper.InvitationOptions{ID: 4}) + require.ErrorIs(t, err, tapper.ErrInvitationVoid) + _, err = tap.InvitationAccept(ctx, tapper.InvitationOptions{}) + require.Error(t, err, "an id is required") + require.Len(t, calls, 5) +} diff --git a/pkg/tapper/tap_keg.go b/pkg/tapper/tap_keg.go index 90ebf300..e4e04610 100644 --- a/pkg/tapper/tap_keg.go +++ b/pkg/tapper/tap_keg.go @@ -75,19 +75,20 @@ func (t *Tap) KegGrants(ctx context.Context, opts KegGrantsOptions) ([]HubGrant, return ListGrants(ctx, hubURL, token, ns, alias) } -// KegGrant upserts a grant (user → role) on a keg. -func (t *Tap) KegGrant(ctx context.Context, opts KegGrantOptions) error { +// KegGrant grants a user a role on a keg. An existing grantee's role changes +// now; anyone else is invited and gains access when they accept. +func (t *Tap) KegGrant(ctx context.Context, opts KegGrantOptions) (AccessChange, error) { role := strings.TrimSpace(opts.Role) if !kegGrantRoles[role] { - return fmt.Errorf("invalid role %q: expected viewer, editor, or admin", opts.Role) + return AccessChange{}, fmt.Errorf("invalid role %q: expected viewer, editor, or admin", opts.Role) } user := strings.TrimPrefix(strings.TrimSpace(opts.User), "@") if user == "" { - return fmt.Errorf("a username is required") + return AccessChange{}, fmt.Errorf("a username is required") } ns, alias, hubURL, token, err := t.resolveKegAdminRef(opts.Keg, opts.Namespace, opts.Hub) if err != nil { - return err + return AccessChange{}, err } return CreateGrant(ctx, hubURL, token, ns, alias, user, role) } diff --git a/pkg/tapper/tap_keg_test.go b/pkg/tapper/tap_keg_test.go index 25c632b5..18ef9dde 100644 --- a/pkg/tapper/tap_keg_test.go +++ b/pkg/tapper/tap_keg_test.go @@ -60,17 +60,33 @@ func TestKegGrant_Upsert(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]string{"username": "bob", "role": "editor"}) }) tap, fx, _ := newRemoteHubTap(t, h) - require.NoError(t, tap.KegGrant(fx.Context(), tapper.KegGrantOptions{Keg: "@jlrickert/example", User: "@bob", Role: "editor"})) + change, err := tap.KegGrant(fx.Context(), tapper.KegGrantOptions{Keg: "@jlrickert/example", User: "@bob", Role: "editor"}) + require.NoError(t, err) + require.False(t, change.Invited(), "an existing grantee's role changes immediately") require.Equal(t, map[string]string{"username": "bob", "role": "editor"}, gotBody) } +// A new grantee is invited: the hub answers 202 and access waits on them. +func TestKegGrant_Invites(t *testing.T) { + t.Parallel() + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{"status": "invited", "invitation_id": 7, "username": "bob", "role": "editor"}) + }) + tap, fx, _ := newRemoteHubTap(t, h) + change, err := tap.KegGrant(fx.Context(), tapper.KegGrantOptions{Keg: "@jlrickert/example", User: "bob", Role: "editor"}) + require.NoError(t, err) + require.True(t, change.Invited()) + require.Equal(t, int64(7), change.InvitationID) +} + func TestKegGrant_InvalidRole(t *testing.T) { t.Parallel() h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("hub should not be contacted for an invalid role") }) tap, fx, _ := newRemoteHubTap(t, h) - err := tap.KegGrant(fx.Context(), tapper.KegGrantOptions{Keg: "@jlrickert/example", User: "bob", Role: "superuser"}) + _, err := tap.KegGrant(fx.Context(), tapper.KegGrantOptions{Keg: "@jlrickert/example", User: "bob", Role: "superuser"}) require.Error(t, err) require.Contains(t, err.Error(), "invalid role") } diff --git a/pkg/tapper/tap_namespace.go b/pkg/tapper/tap_namespace.go index 632b727a..64e7431a 100644 --- a/pkg/tapper/tap_namespace.go +++ b/pkg/tapper/tap_namespace.go @@ -113,19 +113,20 @@ func (t *Tap) NamespaceMembers(ctx context.Context, opts NamespaceMembersOptions return ListNamespaceMembers(ctx, hubURL, token, ns) } -// NamespaceAddMember upserts a member (user → role) into a namespace. -func (t *Tap) NamespaceAddMember(ctx context.Context, opts NamespaceAddMemberOptions) error { +// NamespaceAddMember invites a member (user → role) to a namespace, or +// changes an existing member's role. A new member joins when they accept. +func (t *Tap) NamespaceAddMember(ctx context.Context, opts NamespaceAddMemberOptions) (AccessChange, error) { role := strings.TrimSpace(opts.Role) if !namespaceMemberRoles[role] { - return fmt.Errorf("invalid role %q: expected owner, admin, or member", opts.Role) + return AccessChange{}, fmt.Errorf("invalid role %q: expected owner, admin, or member", opts.Role) } user := strings.TrimPrefix(strings.TrimSpace(opts.User), "@") if user == "" { - return fmt.Errorf("a username is required") + return AccessChange{}, fmt.Errorf("a username is required") } ns, hubURL, token, err := t.resolveNamespaceHub(opts.Namespace, opts.Hub) if err != nil { - return err + return AccessChange{}, err } return AddNamespaceMember(ctx, hubURL, token, ns, user, role) } diff --git a/pkg/tapper/tap_namespace_test.go b/pkg/tapper/tap_namespace_test.go index c5551466..b2ebcbe4 100644 --- a/pkg/tapper/tap_namespace_test.go +++ b/pkg/tapper/tap_namespace_test.go @@ -69,7 +69,8 @@ func TestNamespaceAddMember(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]string{"username": "bob", "role": "member"}) }) tap, fx, _ := newRemoteHubTap(t, h) - require.NoError(t, tap.NamespaceAddMember(fx.Context(), tapper.NamespaceAddMemberOptions{Namespace: "acme", User: "@bob", Role: "member"})) + _, err := tap.NamespaceAddMember(fx.Context(), tapper.NamespaceAddMemberOptions{Namespace: "acme", User: "@bob", Role: "member"}) + require.NoError(t, err) require.Equal(t, map[string]string{"username": "bob", "role": "member"}, gotBody) } @@ -80,7 +81,7 @@ func TestNamespaceAddMember_InvalidRole(t *testing.T) { }) tap, fx, _ := newRemoteHubTap(t, h) // "viewer" is a keg grant role, not a namespace membership role. - err := tap.NamespaceAddMember(fx.Context(), tapper.NamespaceAddMemberOptions{Namespace: "acme", User: "bob", Role: "viewer"}) + _, err := tap.NamespaceAddMember(fx.Context(), tapper.NamespaceAddMemberOptions{Namespace: "acme", User: "bob", Role: "viewer"}) require.Error(t, err) require.Contains(t, err.Error(), "invalid role") }