diff --git a/cmd/auth.go b/cmd/auth.go index a0a535b..5bacdea 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -132,6 +132,7 @@ Examples: botInfo, _ := me["bot"].(map[string]interface{}) workspaceName, _ := botInfo["workspace_name"].(string) name, _ := me["name"].(string) + integrationType := detectIntegrationType(botInfo) render.Title("✓", "Authenticated") @@ -147,6 +148,12 @@ Examples: render.Field("Workspace", workspaceName) render.Field("Bot", name) + if integrationType != "" { + render.Field("Integration", integrationType) + if integrationType == "internal" { + render.Field("Root page", "not allowed (share a parent page)") + } + } // Show other available profiles if len(profiles) > 1 { @@ -371,8 +378,16 @@ Examples: name, _ := me["name"].(string) botInfo, _ := me["bot"].(map[string]interface{}) workspace, _ := botInfo["workspace_name"].(string) + integrationType := detectIntegrationType(botInfo) fmt.Printf(" ✓ Auth: %s\n", name) fmt.Printf(" ✓ Workspace: %s\n", workspace) + if integrationType != "" { + fmt.Printf(" ✓ Integration: %s\n", integrationType) + if integrationType == "internal" { + fmt.Println(" note: internal integrations cannot create pages at the workspace root;") + fmt.Println(" create a parent page in Notion and share it with this integration first.") + } + } // Check 3: Can search result, err := c.Search("", "", 1, "") @@ -399,3 +414,28 @@ func init() { authCmd.AddCommand(authDoctorCmd) authCmd.AddCommand(authSwitchCmd) } + +// detectIntegrationType inspects the bot object returned by +// GET /v1/users/me and classifies the integration as "internal", +// "public", or "" when the shape is unrecognizable. +// +// Notion's response includes `bot.owner.type`, which is: +// - "workspace" for internal integrations (owned by the workspace) +// - "user" for public integrations installed by an individual user +// +// Ref: https://developers.notion.com/reference/user#bot +func detectIntegrationType(botInfo map[string]interface{}) string { + owner, ok := botInfo["owner"].(map[string]interface{}) + if !ok { + return "" + } + ownerType, _ := owner["type"].(string) + switch ownerType { + case "workspace": + return "internal" + case "user": + return "public" + default: + return "" + } +} diff --git a/cmd/auth_integration_type_test.go b/cmd/auth_integration_type_test.go new file mode 100644 index 0000000..5b0d894 --- /dev/null +++ b/cmd/auth_integration_type_test.go @@ -0,0 +1,53 @@ +package cmd + +import "testing" + +func TestDetectIntegrationType(t *testing.T) { + cases := []struct { + name string + bot map[string]interface{} + want string + }{ + { + name: "internal (workspace-owned)", + bot: map[string]interface{}{ + "owner": map[string]interface{}{"type": "workspace", "workspace": true}, + }, + want: "internal", + }, + { + name: "public (user-owned)", + bot: map[string]interface{}{ + "owner": map[string]interface{}{"type": "user"}, + }, + want: "public", + }, + { + name: "missing owner", + bot: map[string]interface{}{}, + want: "", + }, + { + name: "unknown owner type", + bot: map[string]interface{}{ + "owner": map[string]interface{}{"type": "planet"}, + }, + want: "", + }, + { + name: "non-map owner", + bot: map[string]interface{}{ + "owner": "oops", + }, + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := detectIntegrationType(tc.bot) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} diff --git a/cmd/auth_test.go b/cmd/auth_test.go index d7e85bc..45fd4c4 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -42,6 +42,10 @@ func setupAuthTest(t *testing.T) *httptest.Server { "bot": map[string]interface{}{ "workspace_name": workspace, "workspace_id": "ws-123", + "owner": map[string]interface{}{ + "type": "workspace", + "workspace": true, + }, }, }) }) diff --git a/internal/client/client.go b/internal/client/client.go index 1b5cec5..a3b7014 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -408,6 +408,10 @@ func errorHint(code, message string) string { case "rate_limited": return "Too many requests. Wait a moment and try again" case "validation_error": + if strings.Contains(message, "Internal integrations aren't owned") || + strings.Contains(message, "insert_content") { + return internalIntegrationRootPageHint + } if strings.Contains(message, "is not a property") { return "Check property names with 'notion db view ' or 'notion page props '" } @@ -421,3 +425,13 @@ func errorHint(code, message string) string { } return "" } + +// internalIntegrationRootPageHint is the one-paragraph explanation the CLI +// prints when an internal integration tries to create a workspace-root page. +// It's a multi-line string because that's the shape most users need: the +// API error is accurate but not directly actionable. +const internalIntegrationRootPageHint = "Internal integrations can't create pages at the workspace root.\n" + + " Workaround: create (or pick) a parent page in the Notion UI, share\n" + + " it with this integration, then pass its ID as the parent:\n" + + " notion page create --title \"...\"\n" + + " To list pages shared with your integration: notion page list" diff --git a/internal/client/client_test.go b/internal/client/client_test.go index b86e8ef..920c342 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -29,6 +29,8 @@ func TestErrorHint(t *testing.T) { {"rate_limited", "Rate limited", "Wait"}, {"validation_error", "is not a property that exists", "notion db view"}, {"validation_error", "body failed validation", "--debug"}, + {"validation_error", "Internal integrations aren't owned by a single user, so creating workspace-level private pages is not supported.", "share"}, + {"validation_error", "use a public integration with insert_content capability", "share"}, {"conflict_error", "conflict", "Retry"}, {"internal_server_error", "error", "Notion's servers"}, {"service_unavailable", "unavailable", "Try again"},