Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ func NewConfigCmd() *cobra.Command {
- The CLI provides commands to create, list, and revoke API keys
- Keys are generated server-side and can be used for programmatic access
- Keys are stored in the system keyring alongside tokens
- Keys are created with `JsonAPI` scope and always carry a grant (spaces × read/read-write); the CLI never creates an unrestricted key. Grant logic lives in `core/apikeygrant.go`
- `CreateAPIKey` probes the server for grant support and reads the key back, revoking it if the stored access differs from the request

### Testing Strategy
- **Unit Tests**: Test individual functions and logic in isolation
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ TANTIVY_ASSET = $(TANTIVY_ASSET_$(GOOS)_$(GOARCH))
TANTIVY_URL = https://github.com/anyproto/tantivy-go/releases/download/$(TANTIVY_VERSION)/$(TANTIVY_ASSET).tar.gz
CGO_LDFLAGS := -L$(TANTIVY_LIB_PATH)

GOLANGCI_LINT_VERSION := v2.7.2
GOLANGCI_LINT_VERSION := v2.12.2

##@ Build

Expand Down
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ anytype space join <invite-link>
anytype space list

# Create an API key for programmatic access
anytype auth apikey create "my-bot-api-key"
anytype auth apikey create "my-bot-api-key" --all-spaces --read-write
```

Once running, the API is available at `http://127.0.0.1:31012`. Use your API key to authenticate requests to the endpoints described on the [Developer Portal](https://developers.anytype.io). See [Network Configuration](#network-configuration) for remote access options.
Expand Down Expand Up @@ -128,7 +128,19 @@ By default, the server binds to `127.0.0.1` (localhost only) on ports 31010-3101
| 31012 | API | HTTP API server endpoint ⭐ |


You can change the API listen address using `--listen-address` (e.g., `--listen-address 0.0.0.0:31012`). For remote access, you can also use a reverse proxy, SSH tunnel, or Docker port mapping to expose the local ports.
You can change the JSON API listen address with `--listen-address` on `serve`, `service install`, `auth login` or `auth create` (e.g., `--listen-address 0.0.0.0:31012`). The address is remembered, including across logout, so later commands use it without the flag. The JSON API starts once an account is logged in. `serve` prints the address it will use at startup, and `anytype auth status` and the login commands print it too. For remote access, you can also use a reverse proxy, SSH tunnel, or Docker port mapping to expose the local ports.

The server only accepts requests whose `Host` is `localhost` or an IP address, and browser requests from local origins. If you reach it by another hostname (for example through a reverse proxy) or from a web page on another origin, allow them explicitly in the server's environment:

| Variable | Applies to | Value |
| --- | --- | --- |
| `ANYTYPE_API_ALLOWED_HOSTS` | HTTP API (31012) | Comma-separated hostnames, e.g. `anytype.example.com` |
| `ANYTYPE_API_ALLOWED_ORIGINS` | HTTP API (31012) | Comma-separated exact origins, e.g. `https://app.example.com` |
| `ANYTYPE_GRPCWEB_ALLOWED_HOSTS` | gRPC-Web (31011) | Comma-separated hostnames |
| `ANYTYPE_GRPCWEB_ALLOWED_ORIGINS` | gRPC-Web (31011) | Comma-separated exact origins |
| `ANYTYPE_GRPCWEB_ENABLE_WEBSOCKETS` | gRPC-Web (31011) | `1` to enable the WebSocket transport (off by default) |

Set them where `anytype serve` runs: in your shell, your Docker/Compose environment, or the user service's definition.

**Security note**: Always keep your API keys safe. If ports are exposed externally, third parties with your API key could gain unauthorized access to the spaces your headless instance has access to.

Expand All @@ -155,8 +167,9 @@ anytype auth logout
Manage API keys for programmatic access:

```bash
# Create a new API key
anytype auth apikey create <name>
# Create a new API key: choose its spaces and whether it can write
anytype auth apikey create <name> --space <id|name> [--space ...] --read-only
anytype auth apikey create <name> --all-spaces --read-write

# List all API keys
anytype auth apikey list
Expand All @@ -165,6 +178,20 @@ anytype auth apikey list
anytype auth apikey revoke <key-id>
```

Every key is limited to the spaces and permission you choose; there is no default. Use `anytype space list` to find space names and Ids. A key limited to specific spaces, or a read-only key, works with the JSON API v2 only; an `--all-spaces --read-write` key works with v1 and v2.

#### Upgrading to the JSON API v2

Keys created by earlier CLI versions keep working with the JSON API v1, but the v2 API rejects them. To move an integration to v2:

1. Create a new key with the access it needs, using **the same name** as the old key. On v2, a key can only delete objects created under its name.
2. Check that the integration works with the new key, then switch it over.
3. Revoke the old key with `anytype auth apikey revoke <key-id>`.

Keep the old key if the integration calls the gRPC API directly: new keys work only with the JSON API.

After updating the CLI, restart the service (`anytype service restart`) so it runs the new version; `apikey create` refuses to create keys on an older running server. Keys created by this version don't work if you downgrade to an earlier one.

### Space Management

Work with Anytype spaces:
Expand Down
92 changes: 88 additions & 4 deletions cmd/auth/apikey/create/create.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,118 @@
package create

import (
"errors"
"fmt"
"strings"

"github.com/spf13/cobra"

"github.com/anyproto/anytype-cli/cmd/cmdutil"
"github.com/anyproto/anytype-cli/core"
"github.com/anyproto/anytype-cli/core/config"
"github.com/anyproto/anytype-cli/core/output"
)

// Server calls, replaceable in tests.
var (
listSpaces = core.ListSpaces
techSpaceId = config.GetTechSpaceIdFromConfig
createAPIKey = core.CreateAPIKey
)

func NewCreateCmd() *cobra.Command {
var flags core.GrantFlags

cmd := &cobra.Command{
Use: "create <name>",
Short: "Create a new API key",
Long: "Create a new API key for programmatic access to Anytype",
Args: cmdutil.ExactArgs(1, "cannot create API key: name argument required"),
Long: `Create a new API key for programmatic access to Anytype.

You must choose which spaces the key can access and whether it can write:
--space <id|name> (repeatable) or --all-spaces
--read-only or --read-write

Keys limited to specific spaces, or read-only keys, work with the JSON API v2 only.`,
Example: ` anytype auth apikey create my-app --space "Personal" --read-only
anytype auth apikey create my-app --all-spaces --read-write`,
Args: cmdutil.ExactArgs(1, "cannot create API key: name argument required"),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]

resp, err := core.CreateAPIKey(name)
if err := core.ValidateAPIKeyName(name); err != nil {
return output.Error("Failed to create API key: %w", err)
}
if err := core.ValidateGrantFlags(flags); err != nil {
if errors.Is(err, core.ErrSpaceChoiceRequired) {
return output.Error("Failed to create API key: %w%s", err, spaceChoices())
}
return output.Error("Failed to create API key: %w", err)
}

var resolved []core.ResolvedSpace
if len(flags.Spaces) > 0 {
spaces, err := listSpaces()
if err != nil {
return output.Error("Failed to list spaces: %w", err)
}
techId, err := techSpaceId()
if err != nil {
return output.Error("Failed to read tech space Id: %w", err)
}
resolved, err = core.ResolveSpaces(flags.Spaces, spaces, techId)
if err != nil {
return output.Error("Failed to create API key: %w", err)
}
for _, space := range resolved {
if space.IsTech {
output.Warning("The key can access the tech space, which holds account internals; writing to it can break your account")
}
}
}

grant, err := core.BuildGrant(flags, resolved)
if err != nil {
return output.Error("Failed to create API key: %w", err)
}

created, err := createAPIKey(name, grant)
if err != nil {
return output.Error("Failed to create API key: %w", err)
}

output.Success("API key created successfully")
output.Info("Name: %s", name)
output.Info("Key: %s", resp.AppKey)
output.Info("Key: %s", created.Key)
output.Info("Access: %s", core.DescribeGrant(created.App.Grant, resolved))
if core.GrantWorksOnV1(created.App.Grant) {
output.Info("Works with: JSON API v1 and v2")
} else {
output.Info("Works with: JSON API v2 only")
}

return nil
},
}

cmd.Flags().StringArrayVar(&flags.Spaces, "space", nil, "Space the key can access, by Id or exact name (repeatable)")
cmd.Flags().BoolVar(&flags.AllSpaces, "all-spaces", false, "Let the key access all spaces, including ones created later")
cmd.Flags().BoolVar(&flags.ReadOnly, "read-only", false, "Let the key read but not change data")
cmd.Flags().BoolVar(&flags.ReadWrite, "read-write", false, "Let the key read and change data")

return cmd
}

// spaceChoices lists the user's spaces to help pick --space values. It is best
// effort: without a running server the error is returned without the list.
func spaceChoices() string {
spaces, err := listSpaces()
if err != nil || len(spaces) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\nYour spaces:")
for _, space := range spaces {
fmt.Fprintf(&b, "\n %s (%s)", space.Name, space.SpaceId)
}
return b.String()
}
122 changes: 122 additions & 0 deletions cmd/auth/apikey/create/create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package create

import (
"errors"
"io"
"strings"
"testing"

"github.com/anyproto/anytype-cli/core"
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
)

// stubServer replaces the server calls and records whether a key was created.
func stubServer(t *testing.T, spaces []core.SpaceListItem) *bool {
t.Helper()
created := false
origList, origTech, origCreate := listSpaces, techSpaceId, createAPIKey
listSpaces = func() ([]core.SpaceListItem, error) { return spaces, nil }
techSpaceId = func() (string, error) { return "bafyreitech.tech", nil }
createAPIKey = func(name string, grant *model.AccountAuthAppGrant) (*core.CreatedAPIKey, error) {
created = true
return &core.CreatedAPIKey{Key: "secret", App: &model.AccountAuthAppInfo{AppName: name, Scope: model.AccountAuth_JsonAPI, Grant: grant}}, nil
}
t.Cleanup(func() { listSpaces, techSpaceId, createAPIKey = origList, origTech, origCreate })
return &created
}

func runCreate(args ...string) error {
cmd := NewCreateCmd()
cmd.SetArgs(args)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
return cmd.Execute()
}

func TestCreateCommandFlags(t *testing.T) {
cmd := NewCreateCmd()
for _, name := range []string{"space", "all-spaces", "read-only", "read-write"} {
if cmd.Flag(name) == nil {
t.Errorf("flag --%s not found", name)
}
}
}

func TestCreateRequiresExplicitChoices(t *testing.T) {
spaces := []core.SpaceListItem{{SpaceId: "bafyreia.one", Name: "Personal"}}

tests := []struct {
name string
args []string
wantErr error
wantMsg string
}{
{"no space choice lists the spaces", []string{"my-app", "--read-only"}, core.ErrSpaceChoiceRequired, "Personal (bafyreia.one)"},
{"no permission choice", []string{"my-app", "--all-spaces"}, core.ErrPermChoiceRequired, ""},
{"no choices at all", []string{"my-app"}, core.ErrSpaceChoiceRequired, ""},
{"conflicting space flags", []string{"my-app", "--all-spaces", "--space", "Personal", "--read-only"}, core.ErrConflictingSpaceFlags, ""},
{"conflicting permission flags", []string{"my-app", "--all-spaces", "--read-only", "--read-write"}, core.ErrConflictingPermFlags, ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
created := stubServer(t, spaces)

err := runCreate(tt.args...)

if !errors.Is(err, tt.wantErr) {
t.Fatalf("error = %v, want %v", err, tt.wantErr)
}
if tt.wantMsg != "" && !strings.Contains(err.Error(), tt.wantMsg) {
t.Errorf("error = %q, want it to contain %q", err, tt.wantMsg)
}
if *created {
t.Error("no key may be created without both choices")
}
})
}
}

func TestCreateRejectsInvalidName(t *testing.T) {
created := stubServer(t, nil)

err := runCreate(strings.Repeat("a", 129), "--all-spaces", "--read-only")

if err == nil {
t.Fatal("expected an error for a name over 128 bytes")
}
if *created {
t.Error("no key may be created with an invalid name")
}
}

func TestCreateRejectsUnknownSpace(t *testing.T) {
created := stubServer(t, []core.SpaceListItem{{SpaceId: "bafyreia.one", Name: "Personal"}})

err := runCreate("my-app", "--space", "Nope", "--read-only")

if err == nil || !strings.Contains(err.Error(), `"Nope" not found`) {
t.Fatalf("error = %v, want unknown space error", err)
}
if *created {
t.Error("no key may be created for an unknown space")
}
}

func TestCreateSendsResolvedGrant(t *testing.T) {
stubServer(t, []core.SpaceListItem{{SpaceId: "bafyreia.one", Name: "Personal"}})
var sent *model.AccountAuthAppGrant
createAPIKey = func(name string, grant *model.AccountAuthAppGrant) (*core.CreatedAPIKey, error) {
sent = grant
return &core.CreatedAPIKey{Key: "secret", App: &model.AccountAuthAppInfo{AppName: name, Grant: grant}}, nil
}

err := runCreate("my-app", "--space", "Personal", "--read-write")

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sent == nil || len(sent.SpaceIds) != 1 || sent.SpaceIds[0] != "bafyreia.one" || sent.Perm != model.AccountAuthAppGrant_ReadWrite {
t.Errorf("sent grant = %+v, want Personal read-write", sent)
}
}
11 changes: 9 additions & 2 deletions cmd/auth/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@ func NewCreateCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]

accountKey, accountId, savedToKeyring, err := core.CreateWallet(name, rootPath, listenAddress, networkConfigPath)
apiAddr, explicit := cmdutil.APIListenAddr(cmd, listenAddress)
accountKey, accountId, savedToKeyring, err := core.CreateWallet(name, rootPath, apiAddr, networkConfigPath)
if err != nil {
return output.Error("Failed to create account: %w", err)
}
if explicit {
if err := config.SetApiListenAddrToConfig(apiAddr); err != nil {
output.Warning("Failed to remember the JSON API address: %v", err)
}
}

output.Success("Bot account created successfully!")

Expand Down Expand Up @@ -70,13 +76,14 @@ func NewCreateCmd() *cobra.Command {
} else {
output.Success("Account key saved to config file.")
}
output.Banner("JSON API listening on " + config.APIURL(apiAddr))

return nil
},
}

cmd.Flags().StringVar(&rootPath, "root-path", "", "Root path for account data")
cmd.Flags().StringVar(&listenAddress, "listen-address", config.DefaultAPIAddress, "API listen address in `host:port` format")
cmdutil.AddListenAddressFlag(cmd, &listenAddress)
cmd.Flags().StringVar(&networkConfigPath, "network-config", "", "Path to custom network configuration YAML (for self-hosted)")

return cmd
Expand Down
12 changes: 10 additions & 2 deletions cmd/auth/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package login
import (
"github.com/spf13/cobra"

"github.com/anyproto/anytype-cli/cmd/cmdutil"
"github.com/anyproto/anytype-cli/core"
"github.com/anyproto/anytype-cli/core/config"
"github.com/anyproto/anytype-cli/core/output"
Expand All @@ -19,18 +20,25 @@ func NewLoginCmd() *cobra.Command {
Short: "Log in to your bot account",
Long: "Authenticate using your account key to access your Anytype bot account and stored data. Use --network-config for self-hosted networks.",
RunE: func(cmd *cobra.Command, args []string) error {
if err := core.Login(accountKey, rootPath, listenAddress, networkConfigPath); err != nil {
apiAddr, explicit := cmdutil.APIListenAddr(cmd, listenAddress)
if err := core.Login(accountKey, rootPath, apiAddr, networkConfigPath); err != nil {
return output.Error("Failed to log in: %w", err)
}
if explicit {
if err := config.SetApiListenAddrToConfig(apiAddr); err != nil {
output.Warning("Failed to remember the JSON API address: %v", err)
}
}
output.Success("Successfully logged in")
output.Banner("JSON API listening on " + config.APIURL(apiAddr))
return nil

},
}

cmd.Flags().StringVar(&accountKey, "account-key", "", "Account key for authentication")
cmd.Flags().StringVar(&rootPath, "path", "", "Root path for account data")
cmd.Flags().StringVar(&listenAddress, "listen-address", config.DefaultAPIAddress, "API listen address in `host:port` format")
cmdutil.AddListenAddressFlag(cmd, &listenAddress)
cmd.Flags().StringVar(&networkConfigPath, "network-config", "", "Path to custom network configuration YAML (for self-hosted)")

return cmd
Expand Down
Loading
Loading